From 3b9852c113c4044954ce89f21b6682dd8113d2db Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick.esnault@corp.ovh.com>
Date: Fri, 30 Nov 2018 13:03:45 +0100
Subject: [PATCH 01/17] feat(ui): display spawn info

Signed-off-by: Yvonnick Esnault <yvonnick.esnault@corp.ovh.com>
---
 ui/src/app/views/workflow/run/node/pipeline/pipeline.html  | 4 ++--
 .../run/node/pipeline/spawninfo/spawninfo.component.ts     | 7 ++++---
 .../workflow/run/node/pipeline/spawninfo/spawninfo.html    | 4 ++--
 ui/src/assets/i18n/en.json                                 | 1 +
 ui/src/assets/i18n/fr.json                                 | 1 +
 5 files changed, 10 insertions(+), 7 deletions(-)

diff --git a/ui/src/app/views/workflow/run/node/pipeline/pipeline.html b/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
index 43eaeda06c..32dcf253c9 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
+++ b/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
@@ -55,12 +55,12 @@
                 <div class="log animated fadeIn" *ngIf="selectedRunJob && mapStepStatus">
                     <ul>
                         <li>
-                            <app-workflow-rin-job-spawn-info
+                            <app-workflow-run-job-spawn-info
                               [spawnInfos]="selectedRunJob.spawninfos"
                               [variables]="selectedRunJob.parameters"
                               [job]="selectedRunJob.job"
                               [(displayServicesLogs)]="displayServiceLogs">
-                            </app-workflow-rin-job-spawn-info>
+                            </app-workflow-run-job-spawn-info>
                         </li>
                         <ng-container *ngIf="!displayServiceLogs">
                             <li *ngFor="let step of selectedRunJob.job.action.actions; let i = index">
diff --git a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
index e98f5126e9..abc809f389 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
+++ b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
@@ -1,4 +1,5 @@
 import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
+import {TranslateService} from '@ngx-translate/core';
 import * as AU from 'ansi_up';
 import {Job} from '../../../../../../model/job.model';
 import {Parameter} from '../../../../../../model/parameter.model';
@@ -6,7 +7,7 @@ import {SpawnInfo} from '../../../../../../model/pipeline.model';
 import {JobVariableComponent} from '../../../../../run/workflow/variables/job.variables.component';
 
 @Component({
-    selector: 'app-workflow-rin-job-spawn-info',
+    selector: 'app-workflow-run-job-spawn-info',
     templateUrl: './spawninfo.html',
     styleUrls: ['./spawninfo.scss']
 })
@@ -42,7 +43,7 @@ export class WorkflowRunJobSpawnInfoComponent {
     _displayServiceLogs: boolean;
     ansi_up = new AU.default;
 
-    constructor() { }
+    constructor(private _translate: TranslateService) {}
 
     refreshDisplayServiceLogsLink() {
       if (this.job && this.job.action && Array.isArray(this.job.action.requirements)) {
@@ -64,7 +65,7 @@ export class WorkflowRunJobSpawnInfoComponent {
         if (msg !== '') {
             return this.ansi_up.ansi_to_html(msg);
         }
-        return '';
+        return this._translate.instant('job_spawn_no_information');
     }
 
     openVariableModal(event: Event): void {
diff --git a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.html b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.html
index 2bf700dc35..7a56027ffd 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.html
+++ b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.html
@@ -1,4 +1,4 @@
-<div class="spawn" *ngIf="spawnInfos">
+<div class="spawn">
     <div class="header pointing" (click)="toggle()">
         <div class="status">
             <i class="icon heartbeat"></i>
@@ -20,7 +20,7 @@
             </a>
         </div>
     </div>
-    <div class="spawnInfos" *ngIf="spawnInfos" [hidden]="!show">
+    <div class="spawnInfos" [hidden]="!show">
         <pre [innerHTML]="getSpawnInfos()">
         </pre>
     </div>
diff --git a/ui/src/assets/i18n/en.json b/ui/src/assets/i18n/en.json
index cde77a6a5d..9803ec6fdd 100644
--- a/ui/src/assets/i18n/en.json
+++ b/ui/src/assets/i18n/en.json
@@ -418,6 +418,7 @@
   "job_delete": "Delete job",
   "job_save": "Save job",
   "job_spawn_title": "Information",
+  "job_spawn_no_information": "No information for now...",
 
   "monitoring": "Monitoring",
 
diff --git a/ui/src/assets/i18n/fr.json b/ui/src/assets/i18n/fr.json
index 23cba201d0..883bfbd411 100644
--- a/ui/src/assets/i18n/fr.json
+++ b/ui/src/assets/i18n/fr.json
@@ -418,6 +418,7 @@
   "job_delete": "Supprimer le job",
   "job_save": "Sauvegarder le job",
   "job_spawn_title": "Informations",
+  "job_spawn_no_information": "Pas d'information pour l'instant...",
 
   "monitoring": "Monitoring",
 

From fd7715aa41918a1b5f4b47aca567a507101b2e83 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Fri, 30 Nov 2018 14:43:06 +0100
Subject: [PATCH 02/17] wip

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_node_job_run_info.go |  3 +--
 engine/api/workflow/dao_node_run.go          | 20 ++++++++++++--------
 engine/api/workflow/dao_run.go               |  3 +--
 engine/api/workflow/execute_node_job_run.go  |  3 +--
 engine/api/workflow/execute_node_run.go      | 11 ++++++++++-
 sdk/messages.go                              |  1 +
 6 files changed, 26 insertions(+), 15 deletions(-)

diff --git a/engine/api/workflow/dao_node_job_run_info.go b/engine/api/workflow/dao_node_job_run_info.go
index 755f05118a..f646a8ff44 100644
--- a/engine/api/workflow/dao_node_job_run_info.go
+++ b/engine/api/workflow/dao_node_job_run_info.go
@@ -7,7 +7,6 @@ import (
 	"time"
 
 	"github.com/go-gorp/gorp"
-
 	"github.com/ovh/cds/engine/api/database/gorpmapping"
 	"github.com/ovh/cds/sdk"
 	"github.com/ovh/cds/sdk/log"
@@ -52,6 +51,6 @@ func insertNodeRunJobInfo(db gorp.SqlExecutor, info *sdk.WorkflowNodeJobRunInfo)
 		return fmt.Errorf("insertNodeRunJobInfo> Unable to insert into workflow_node_run_job_info id = %d", info.WorkflowNodeJobRunID)
 	}
 
-	log.Debug("insertNodeRunJobInfo> on node run: %d (%d)", info.ID, info.WorkflowNodeJobRunID)
+	log.Debug("insertNodeRunJobInfo> on node run: %v (%d)", info.SpawnInfos, info.WorkflowNodeJobRunID)
 	return nil
 }
diff --git a/engine/api/workflow/dao_node_run.go b/engine/api/workflow/dao_node_run.go
index 2c735d67a5..68d117dc37 100644
--- a/engine/api/workflow/dao_node_run.go
+++ b/engine/api/workflow/dao_node_run.go
@@ -9,14 +9,13 @@ import (
 
 	"github.com/go-gorp/gorp"
 	"github.com/lib/pq"
-	"github.com/ovh/venom"
-
 	"github.com/ovh/cds/engine/api/cache"
 	"github.com/ovh/cds/engine/api/database/gorpmapping"
 	"github.com/ovh/cds/engine/api/observability"
 	"github.com/ovh/cds/engine/api/repositoriesmanager"
 	"github.com/ovh/cds/sdk"
 	"github.com/ovh/cds/sdk/log"
+	"github.com/ovh/venom"
 )
 
 const nodeRunFields string = `
@@ -81,7 +80,7 @@ func LoadNodeRun(db gorp.SqlExecutor, projectkey, workflowname string, number, i
 		return nil, sdk.WrapError(err, "Unable to load workflow_node_run proj=%s, workflow=%s, num=%d, node=%d", projectkey, workflowname, number, id)
 	}
 
-	r, err := fromDBNodeRun(rr, loadOpts)
+	r, err := fromDBNodeRun(db, rr, loadOpts)
 	if err != nil {
 		return nil, sdk.WithStack(err)
 	}
@@ -140,7 +139,7 @@ func LoadNodeRunByNodeJobID(db gorp.SqlExecutor, nodeJobRunID int64, loadOpts Lo
 		return nil, sdk.WrapError(err, "Unable to load workflow_node_run node_job_id=%d", nodeJobRunID)
 	}
 
-	r, err := fromDBNodeRun(rr, loadOpts)
+	r, err := fromDBNodeRun(db, rr, loadOpts)
 	if err != nil {
 		return nil, sdk.WithStack(err)
 	}
@@ -185,7 +184,7 @@ func LoadAndLockNodeRunByID(ctx context.Context, db gorp.SqlExecutor, id int64,
 		}
 		return nil, sdk.WrapError(err, "Unable to load workflow_node_run node=%d", id)
 	}
-	return fromDBNodeRun(rr, LoadRunOptions{})
+	return fromDBNodeRun(db, rr, LoadRunOptions{})
 }
 
 //LoadNodeRunByID load a specific node run on a workflow
@@ -205,7 +204,7 @@ func LoadNodeRunByID(db gorp.SqlExecutor, id int64, loadOpts LoadRunOptions) (*s
 		return nil, sdk.WrapError(err, "Unable to load workflow_node_run node=%d", id)
 	}
 
-	r, err := fromDBNodeRun(rr, loadOpts)
+	r, err := fromDBNodeRun(db, rr, loadOpts)
 	if err != nil {
 		return nil, sdk.WithStack(err)
 	}
@@ -248,7 +247,7 @@ func nodeRunExist(db gorp.SqlExecutor, nodeID, num int64, subnumber int) (bool,
 	return nb > 0, err
 }
 
-func fromDBNodeRun(rr NodeRun, opts LoadRunOptions) (*sdk.WorkflowNodeRun, error) {
+func fromDBNodeRun(db gorp.SqlExecutor, rr NodeRun, opts LoadRunOptions) (*sdk.WorkflowNodeRun, error) {
 	r := new(sdk.WorkflowNodeRun)
 	if rr.WorkflowID.Valid {
 		r.WorkflowID = rr.WorkflowID.Int64
@@ -300,6 +299,11 @@ func fromDBNodeRun(rr NodeRun, opts LoadRunOptions) (*sdk.WorkflowNodeRun, error
 			if rj.Status == sdk.StatusWaiting.String() {
 				rj.QueuedSeconds = time.Now().Unix() - rj.Queued.Unix()
 			}
+			spawnInfos, err := loadNodeRunJobInfo(db, rj.ID)
+			if err != nil {
+				return nil, sdk.WrapError(err, "unable to load spawn infos for runJob: %d", rj.ID)
+			}
+			rj.SpawnInfos = spawnInfos
 		}
 	}
 
@@ -716,7 +720,7 @@ func PreviousNodeRun(db gorp.SqlExecutor, nr sdk.WorkflowNodeRun, n sdk.Workflow
 	if err := db.SelectOne(&rr, query, workflowID, n.Name, nr.VCSBranch, nr.VCSTag, nr.Number, nr.ID); err != nil {
 		return nodeRun, sdk.WrapError(err, "Cannot load previous run on workflow %d node %s nr.VCSBranch:%s nr.VCSTag:%s nr.Number:%d nr.ID:%d ", workflowID, n.Name, nr.VCSBranch, nr.VCSTag, nr.Number, nr.ID)
 	}
-	pNodeRun, errF := fromDBNodeRun(rr, LoadRunOptions{})
+	pNodeRun, errF := fromDBNodeRun(db, rr, LoadRunOptions{})
 	if errF != nil {
 		return nodeRun, sdk.WrapError(errF, "PreviousNodeRun> Cannot read node run")
 	}
diff --git a/engine/api/workflow/dao_run.go b/engine/api/workflow/dao_run.go
index 8d3f2ec450..23cea95fdc 100644
--- a/engine/api/workflow/dao_run.go
+++ b/engine/api/workflow/dao_run.go
@@ -10,7 +10,6 @@ import (
 	"time"
 
 	"github.com/go-gorp/gorp"
-
 	"github.com/ovh/cds/engine/api/database/gorpmapping"
 	"github.com/ovh/cds/engine/api/observability"
 	"github.com/ovh/cds/sdk"
@@ -724,7 +723,7 @@ func syncNodeRuns(db gorp.SqlExecutor, wr *sdk.WorkflowRun, loadOpts LoadRunOpti
 	}
 
 	for _, n := range dbNodeRuns {
-		wnr, err := fromDBNodeRun(n, loadOpts)
+		wnr, err := fromDBNodeRun(db, n, loadOpts)
 		if err != nil {
 			return err
 		}
diff --git a/engine/api/workflow/execute_node_job_run.go b/engine/api/workflow/execute_node_job_run.go
index 0ec12ab159..29af29002d 100644
--- a/engine/api/workflow/execute_node_job_run.go
+++ b/engine/api/workflow/execute_node_job_run.go
@@ -11,7 +11,6 @@ import (
 
 	"github.com/go-gorp/gorp"
 	"github.com/lib/pq"
-
 	"github.com/ovh/cds/engine/api/application"
 	"github.com/ovh/cds/engine/api/cache"
 	"github.com/ovh/cds/engine/api/environment"
@@ -745,7 +744,7 @@ func RestartWorkflowNodeJob(ctx context.Context, db gorp.SqlExecutor, wNodeJob s
 		return sdk.WrapError(errNR, "RestartWorkflowNodeJob> Cannot load node run")
 	}
 
-	//Synchronise struct but not in db
+	//Synchronize struct but not in db
 	sync, errS := SyncNodeRunRunJob(ctx, db, nodeRun, wNodeJob)
 	if errS != nil {
 		return sdk.WrapError(errS, "RestartWorkflowNodeJob> error on sync nodeJobRun")
diff --git a/engine/api/workflow/execute_node_run.go b/engine/api/workflow/execute_node_run.go
index b5fc7cae3b..67ce54940d 100644
--- a/engine/api/workflow/execute_node_run.go
+++ b/engine/api/workflow/execute_node_run.go
@@ -10,7 +10,6 @@ import (
 
 	"github.com/fsamin/go-dump"
 	"github.com/go-gorp/gorp"
-
 	"github.com/ovh/cds/engine/api/cache"
 	"github.com/ovh/cds/engine/api/group"
 	"github.com/ovh/cds/engine/api/observability"
@@ -426,6 +425,12 @@ func addJobsToQueue(ctx context.Context, db gorp.SqlExecutor, stage *sdk.Stage,
 				Message:    spawnInfos,
 				RemoteTime: time.Now(),
 			}}
+		} else {
+			wjob.SpawnInfos = []sdk.SpawnInfo{sdk.SpawnInfo{
+				APITime:    time.Now(),
+				Message:    sdk.SpawnMsg{ID: sdk.MsgSpawnInfoJobInQueue.ID},
+				RemoteTime: time.Now(),
+			}}
 		}
 
 		//Insert in database
@@ -436,6 +441,10 @@ func addJobsToQueue(ctx context.Context, db gorp.SqlExecutor, stage *sdk.Stage,
 		}
 		next()
 
+		if err := AddSpawnInfosNodeJobRun(db, wjob.ID, PrepareSpawnInfos(wjob.SpawnInfos)); err != nil {
+			return nil, sdk.WrapError(err, "Cannot save spawn info job %d", wjob.ID)
+		}
+
 		//Put the job run in database
 		stage.RunJobs = append(stage.RunJobs, wjob)
 
diff --git a/sdk/messages.go b/sdk/messages.go
index 7e36df5f78..718678e36b 100644
--- a/sdk/messages.go
+++ b/sdk/messages.go
@@ -63,6 +63,7 @@ var (
 	MsgSpawnInfoHatcheryErrorSpawn         = &Message{"MsgSpawnInfoHatcheryErrorSpawn", trad{FR: "Une erreur est survenue lorsque la Hatchery %s (%s) a démarré un worker avec le modèle %s après %s, err:%s", EN: "Error while Hatchery %s (%s) spawn worker with model %s after %s, err:%s"}, nil}
 	MsgSpawnInfoHatcheryStartsSuccessfully = &Message{"MsgSpawnInfoHatcheryStartsSuccessfully", trad{FR: "La Hatchery %s (%s) a démarré le worker %s avec succès en %s", EN: "Hatchery %s (%s) spawn worker %s successfully in %s"}, nil}
 	MsgSpawnInfoWorkerEnd                  = &Message{"MsgSpawnInfoWorkerEnd", trad{FR: "Le worker %s a terminé et a passé %s à travailler sur les étapes", EN: "Worker %s finished working on this job and took %s to work on the steps"}, nil}
+	MsgSpawnInfoJobInQueue                 = &Message{"MsgSpawnInfoJobInQueue", trad{FR: "Le job a été pris mis en file d'attente", EN: "Job was taken in queue"}, nil}
 	MsgSpawnInfoJobTaken                   = &Message{"MsgSpawnInfoJobTaken", trad{FR: "Le job %s a été pris par le worker %s", EN: "Job %s was taken by worker %s"}, nil}
 	MsgSpawnInfoJobTakenWorkerVersion      = &Message{"MsgSpawnInfoJobTakenWorkerVersion", trad{FR: "Worker %s version:%s os:%s arch:%s", EN: "Worker %s version:%s os:%s arch:%s"}, nil}
 	MsgSpawnInfoWorkerForJob               = &Message{"MsgSpawnInfoWorkerForJob", trad{FR: "Ce worker %s a été créé pour lancer ce job", EN: "This worker %s was created to take this action"}, nil}

From 54d0112b5f3751a1057828a807f4bd87b9e5ce2d Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Fri, 30 Nov 2018 14:44:23 +0100
Subject: [PATCH 03/17] wip

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/execute_node_run.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/engine/api/workflow/execute_node_run.go b/engine/api/workflow/execute_node_run.go
index 67ce54940d..9b1378c12f 100644
--- a/engine/api/workflow/execute_node_run.go
+++ b/engine/api/workflow/execute_node_run.go
@@ -428,7 +428,7 @@ func addJobsToQueue(ctx context.Context, db gorp.SqlExecutor, stage *sdk.Stage,
 		} else {
 			wjob.SpawnInfos = []sdk.SpawnInfo{sdk.SpawnInfo{
 				APITime:    time.Now(),
-				Message:    sdk.SpawnMsg{ID: sdk.MsgSpawnInfoJobInQueue.ID},
+				Message:    sdk.SpawnMsg{ID: sdk.MsgSpawnInfoJobInQueue.ID, Args: []interface{}{}},
 				RemoteTime: time.Now(),
 			}}
 		}

From f2e9087c1ea474757a4f083262e891f868151528 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Fri, 30 Nov 2018 14:45:40 +0100
Subject: [PATCH 04/17] wip

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 sdk/messages.go | 1 +
 1 file changed, 1 insertion(+)

diff --git a/sdk/messages.go b/sdk/messages.go
index 718678e36b..e399805d52 100644
--- a/sdk/messages.go
+++ b/sdk/messages.go
@@ -126,6 +126,7 @@ var Messages = map[string]*Message{
 	MsgSpawnInfoHatcheryErrorSpawn.ID:         MsgSpawnInfoHatcheryErrorSpawn,
 	MsgSpawnInfoHatcheryStartsSuccessfully.ID: MsgSpawnInfoHatcheryStartsSuccessfully,
 	MsgSpawnInfoWorkerEnd.ID:                  MsgSpawnInfoWorkerEnd,
+	MsgSpawnInfoJobInQueue.ID:                 MsgSpawnInfoJobInQueue,
 	MsgSpawnInfoJobTaken.ID:                   MsgSpawnInfoJobTaken,
 	MsgSpawnInfoJobTakenWorkerVersion.ID:      MsgSpawnInfoJobTakenWorkerVersion,
 	MsgSpawnInfoWorkerForJob.ID:               MsgSpawnInfoWorkerForJob,

From db7e5fd05bfffbf078c75ef1b0badf0a5993e3e3 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Fri, 30 Nov 2018 14:47:32 +0100
Subject: [PATCH 05/17] wip

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 sdk/messages.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/sdk/messages.go b/sdk/messages.go
index e399805d52..676eb823ab 100644
--- a/sdk/messages.go
+++ b/sdk/messages.go
@@ -63,7 +63,7 @@ var (
 	MsgSpawnInfoHatcheryErrorSpawn         = &Message{"MsgSpawnInfoHatcheryErrorSpawn", trad{FR: "Une erreur est survenue lorsque la Hatchery %s (%s) a démarré un worker avec le modèle %s après %s, err:%s", EN: "Error while Hatchery %s (%s) spawn worker with model %s after %s, err:%s"}, nil}
 	MsgSpawnInfoHatcheryStartsSuccessfully = &Message{"MsgSpawnInfoHatcheryStartsSuccessfully", trad{FR: "La Hatchery %s (%s) a démarré le worker %s avec succès en %s", EN: "Hatchery %s (%s) spawn worker %s successfully in %s"}, nil}
 	MsgSpawnInfoWorkerEnd                  = &Message{"MsgSpawnInfoWorkerEnd", trad{FR: "Le worker %s a terminé et a passé %s à travailler sur les étapes", EN: "Worker %s finished working on this job and took %s to work on the steps"}, nil}
-	MsgSpawnInfoJobInQueue                 = &Message{"MsgSpawnInfoJobInQueue", trad{FR: "Le job a été pris mis en file d'attente", EN: "Job was taken in queue"}, nil}
+	MsgSpawnInfoJobInQueue                 = &Message{"MsgSpawnInfoJobInQueue", trad{FR: "Le job a été pris mis en file d'attente", EN: "Job was queued"}, nil}
 	MsgSpawnInfoJobTaken                   = &Message{"MsgSpawnInfoJobTaken", trad{FR: "Le job %s a été pris par le worker %s", EN: "Job %s was taken by worker %s"}, nil}
 	MsgSpawnInfoJobTakenWorkerVersion      = &Message{"MsgSpawnInfoJobTakenWorkerVersion", trad{FR: "Worker %s version:%s os:%s arch:%s", EN: "Worker %s version:%s os:%s arch:%s"}, nil}
 	MsgSpawnInfoWorkerForJob               = &Message{"MsgSpawnInfoWorkerForJob", trad{FR: "Ce worker %s a été créé pour lancer ce job", EN: "This worker %s was created to take this action"}, nil}

From 892bb87819b263749cc63fec04cda6ccd3836e7b Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Fri, 30 Nov 2018 14:56:06 +0100
Subject: [PATCH 06/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_node_run.go | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/engine/api/workflow/dao_node_run.go b/engine/api/workflow/dao_node_run.go
index 68d117dc37..a479cfc678 100644
--- a/engine/api/workflow/dao_node_run.go
+++ b/engine/api/workflow/dao_node_run.go
@@ -9,13 +9,14 @@ import (
 
 	"github.com/go-gorp/gorp"
 	"github.com/lib/pq"
+	"github.com/ovh/venom"
+
 	"github.com/ovh/cds/engine/api/cache"
 	"github.com/ovh/cds/engine/api/database/gorpmapping"
 	"github.com/ovh/cds/engine/api/observability"
 	"github.com/ovh/cds/engine/api/repositoriesmanager"
 	"github.com/ovh/cds/sdk"
 	"github.com/ovh/cds/sdk/log"
-	"github.com/ovh/venom"
 )
 
 const nodeRunFields string = `

From 96468f498b47a5623c63e4ee9c24e8c90b202c8b Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Fri, 30 Nov 2018 15:19:30 +0100
Subject: [PATCH 07/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_node_run.go | 37 ++++++++++++++++++++---------
 engine/api/workflow/dao_run.go      |  3 ++-
 engine/api/workflow_run.go          |  1 +
 3 files changed, 29 insertions(+), 12 deletions(-)

diff --git a/engine/api/workflow/dao_node_run.go b/engine/api/workflow/dao_node_run.go
index a479cfc678..2f85416b97 100644
--- a/engine/api/workflow/dao_node_run.go
+++ b/engine/api/workflow/dao_node_run.go
@@ -81,7 +81,7 @@ func LoadNodeRun(db gorp.SqlExecutor, projectkey, workflowname string, number, i
 		return nil, sdk.WrapError(err, "Unable to load workflow_node_run proj=%s, workflow=%s, num=%d, node=%d", projectkey, workflowname, number, id)
 	}
 
-	r, err := fromDBNodeRun(db, rr, loadOpts)
+	r, err := fromDBNodeRun(rr, loadOpts)
 	if err != nil {
 		return nil, sdk.WithStack(err)
 	}
@@ -107,6 +107,11 @@ func LoadNodeRun(db gorp.SqlExecutor, projectkey, workflowname string, number, i
 		}
 		r.Coverage = cov
 	}
+	if loadOpts.WithSpawnInfos {
+		if err := loadSpawnInfos(db, r); err != nil {
+			return nil, sdk.WrapError(err, "LoadNodeRun>Error load spawn infos %d", r.ID)
+		}
+	}
 	if loadOpts.WithVulnerabilities {
 		vuln, errV := loadVulnerabilityReport(db, r.ID)
 		if errV != nil && !sdk.ErrorIs(errV, sdk.ErrNotFound) {
@@ -118,6 +123,21 @@ func LoadNodeRun(db gorp.SqlExecutor, projectkey, workflowname string, number, i
 
 }
 
+func loadSpawnInfos(db gorp.SqlExecutor, r *sdk.WorkflowNodeRun) error {
+	for s := range r.Stages {
+		stage := r.Stages[s]
+		for j := range stage.RunJobs {
+			rj := stage.RunJobs[j]
+			spawnInfos, err := loadNodeRunJobInfo(db, rj.ID)
+			if err != nil {
+				return sdk.WrapError(err, "unable to load spawn infos for runJob: %d", rj.ID)
+			}
+			rj.SpawnInfos = spawnInfos
+		}
+	}
+	return nil
+}
+
 //LoadNodeRunByNodeJobID load a specific node run on a workflow from a node job run id
 func LoadNodeRunByNodeJobID(db gorp.SqlExecutor, nodeJobRunID int64, loadOpts LoadRunOptions) (*sdk.WorkflowNodeRun, error) {
 	var rr = NodeRun{}
@@ -140,7 +160,7 @@ func LoadNodeRunByNodeJobID(db gorp.SqlExecutor, nodeJobRunID int64, loadOpts Lo
 		return nil, sdk.WrapError(err, "Unable to load workflow_node_run node_job_id=%d", nodeJobRunID)
 	}
 
-	r, err := fromDBNodeRun(db, rr, loadOpts)
+	r, err := fromDBNodeRun(rr, loadOpts)
 	if err != nil {
 		return nil, sdk.WithStack(err)
 	}
@@ -185,7 +205,7 @@ func LoadAndLockNodeRunByID(ctx context.Context, db gorp.SqlExecutor, id int64,
 		}
 		return nil, sdk.WrapError(err, "Unable to load workflow_node_run node=%d", id)
 	}
-	return fromDBNodeRun(db, rr, LoadRunOptions{})
+	return fromDBNodeRun(rr, LoadRunOptions{})
 }
 
 //LoadNodeRunByID load a specific node run on a workflow
@@ -205,7 +225,7 @@ func LoadNodeRunByID(db gorp.SqlExecutor, id int64, loadOpts LoadRunOptions) (*s
 		return nil, sdk.WrapError(err, "Unable to load workflow_node_run node=%d", id)
 	}
 
-	r, err := fromDBNodeRun(db, rr, loadOpts)
+	r, err := fromDBNodeRun(rr, loadOpts)
 	if err != nil {
 		return nil, sdk.WithStack(err)
 	}
@@ -248,7 +268,7 @@ func nodeRunExist(db gorp.SqlExecutor, nodeID, num int64, subnumber int) (bool,
 	return nb > 0, err
 }
 
-func fromDBNodeRun(db gorp.SqlExecutor, rr NodeRun, opts LoadRunOptions) (*sdk.WorkflowNodeRun, error) {
+func fromDBNodeRun(rr NodeRun, opts LoadRunOptions) (*sdk.WorkflowNodeRun, error) {
 	r := new(sdk.WorkflowNodeRun)
 	if rr.WorkflowID.Valid {
 		r.WorkflowID = rr.WorkflowID.Int64
@@ -300,11 +320,6 @@ func fromDBNodeRun(db gorp.SqlExecutor, rr NodeRun, opts LoadRunOptions) (*sdk.W
 			if rj.Status == sdk.StatusWaiting.String() {
 				rj.QueuedSeconds = time.Now().Unix() - rj.Queued.Unix()
 			}
-			spawnInfos, err := loadNodeRunJobInfo(db, rj.ID)
-			if err != nil {
-				return nil, sdk.WrapError(err, "unable to load spawn infos for runJob: %d", rj.ID)
-			}
-			rj.SpawnInfos = spawnInfos
 		}
 	}
 
@@ -721,7 +736,7 @@ func PreviousNodeRun(db gorp.SqlExecutor, nr sdk.WorkflowNodeRun, n sdk.Workflow
 	if err := db.SelectOne(&rr, query, workflowID, n.Name, nr.VCSBranch, nr.VCSTag, nr.Number, nr.ID); err != nil {
 		return nodeRun, sdk.WrapError(err, "Cannot load previous run on workflow %d node %s nr.VCSBranch:%s nr.VCSTag:%s nr.Number:%d nr.ID:%d ", workflowID, n.Name, nr.VCSBranch, nr.VCSTag, nr.Number, nr.ID)
 	}
-	pNodeRun, errF := fromDBNodeRun(db, rr, LoadRunOptions{})
+	pNodeRun, errF := fromDBNodeRun(rr, LoadRunOptions{})
 	if errF != nil {
 		return nodeRun, sdk.WrapError(errF, "PreviousNodeRun> Cannot read node run")
 	}
diff --git a/engine/api/workflow/dao_run.go b/engine/api/workflow/dao_run.go
index 23cea95fdc..072c7ac984 100644
--- a/engine/api/workflow/dao_run.go
+++ b/engine/api/workflow/dao_run.go
@@ -35,6 +35,7 @@ type LoadRunOptions struct {
 	WithCoverage            bool
 	WithArtifacts           bool
 	WithStaticFiles         bool
+	WithSpawnInfos          bool
 	WithTests               bool
 	WithLightTests          bool
 	WithVulnerabilities     bool
@@ -723,7 +724,7 @@ func syncNodeRuns(db gorp.SqlExecutor, wr *sdk.WorkflowRun, loadOpts LoadRunOpti
 	}
 
 	for _, n := range dbNodeRuns {
-		wnr, err := fromDBNodeRun(db, n, loadOpts)
+		wnr, err := fromDBNodeRun(n, loadOpts)
 		if err != nil {
 			return err
 		}
diff --git a/engine/api/workflow_run.go b/engine/api/workflow_run.go
index 3627ed683a..24cfdbf20e 100644
--- a/engine/api/workflow_run.go
+++ b/engine/api/workflow_run.go
@@ -764,6 +764,7 @@ func (api *API) getWorkflowNodeRunHandler() service.Handler {
 			WithStaticFiles:     true,
 			WithCoverage:        true,
 			WithVulnerabilities: true,
+			WithSpawnInfos:      true,
 		})
 		if err != nil {
 			return sdk.WrapError(err, "Unable to load last workflow run")

From 5c563c3b239ab9cf702365923c81c572afa15818 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Fri, 30 Nov 2018 17:49:50 +0100
Subject: [PATCH 08/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_node_job_run_info.go | 27 ++++++++++++-------
 engine/api/workflow/dao_node_run.go          | 28 +++++++++++++-------
 engine/api/workflow/execute_node_run.go      |  6 ++---
 sdk/build.go                                 |  7 ++---
 4 files changed, 44 insertions(+), 24 deletions(-)

diff --git a/engine/api/workflow/dao_node_job_run_info.go b/engine/api/workflow/dao_node_job_run_info.go
index f646a8ff44..5e2ff463d7 100644
--- a/engine/api/workflow/dao_node_job_run_info.go
+++ b/engine/api/workflow/dao_node_job_run_info.go
@@ -4,6 +4,7 @@ import (
 	"database/sql"
 	"encoding/json"
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/go-gorp/gorp"
@@ -13,12 +14,18 @@ import (
 )
 
 //loadNodeRunJobInfo load infos (workflow_node_run_job_infos) for a job (workflow_node_run_job)
-func loadNodeRunJobInfo(db gorp.SqlExecutor, jobID int64) ([]sdk.SpawnInfo, error) {
+func loadNodeRunJobInfo(db gorp.SqlExecutor, jobIDs []int64) ([]sdk.SpawnInfo, error) {
+	ids := make([]string, len(jobIDs))
+	for i := range ids {
+		ids[i] = fmt.Sprintf("%d", jobIDs[i])
+	}
+	idsJoined := strings.Join(ids, ",")
 	res := []struct {
-		Bytes sql.NullString `db:"spawninfos"`
+		Bytes                sql.NullString `db:"spawninfos"`
+		WorkflowNodeJobRunID int64          `db:"workflow_node_job_run_id"`
 	}{}
-	query := "SELECT spawninfos FROM workflow_node_run_job_info WHERE workflow_node_run_job_id = $1"
-	if _, err := db.Select(&res, query, jobID); err != nil {
+	query := "SELECT workflow_node_run_job_id, spawninfos FROM workflow_node_run_job_info WHERE workflow_node_run_job_id = ANY(string_to_array($1, ',')::bigint[])"
+	if _, err := db.Select(&res, query, idsJoined); err != nil {
 		if err == sql.ErrNoRows {
 			return nil, nil
 		}
@@ -26,12 +33,14 @@ func loadNodeRunJobInfo(db gorp.SqlExecutor, jobID int64) ([]sdk.SpawnInfo, erro
 	}
 
 	spawnInfos := []sdk.SpawnInfo{}
-	for _, r := range res {
-		v := []sdk.SpawnInfo{}
-		gorpmapping.JSONNullString(r.Bytes, &v)
-		spawnInfos = append(spawnInfos, v...)
+	for i := range res {
+		spInfos := []sdk.SpawnInfo{}
+		gorpmapping.JSONNullString(res[i].Bytes, &spInfos)
+		for i := range spInfos {
+			spInfos[i].WorkflowNodeJobRunID = res[i].WorkflowNodeJobRunID
+		}
+		spawnInfos = append(spawnInfos, spInfos...)
 	}
-
 	return spawnInfos, nil
 }
 
diff --git a/engine/api/workflow/dao_node_run.go b/engine/api/workflow/dao_node_run.go
index 2f85416b97..7779f82057 100644
--- a/engine/api/workflow/dao_node_run.go
+++ b/engine/api/workflow/dao_node_run.go
@@ -123,16 +123,26 @@ func LoadNodeRun(db gorp.SqlExecutor, projectkey, workflowname string, number, i
 
 }
 
-func loadSpawnInfos(db gorp.SqlExecutor, r *sdk.WorkflowNodeRun) error {
-	for s := range r.Stages {
-		stage := r.Stages[s]
-		for j := range stage.RunJobs {
-			rj := stage.RunJobs[j]
-			spawnInfos, err := loadNodeRunJobInfo(db, rj.ID)
-			if err != nil {
-				return sdk.WrapError(err, "unable to load spawn infos for runJob: %d", rj.ID)
+func loadSpawnInfos(db gorp.SqlExecutor, nr *sdk.WorkflowNodeRun) error {
+	rjIds := make([]int64, 0)
+	for s := range nr.Stages {
+		for j := range nr.Stages[s].RunJobs {
+			rjIds = append(rjIds, nr.Stages[s].RunJobs[j].ID)
+		}
+	}
+
+	spawnInfos, err := loadNodeRunJobInfo(db, rjIds)
+	if err != nil {
+		return sdk.WrapError(err, "unable to load spawn infos")
+	}
+
+	for s := range nr.Stages {
+		for j := range nr.Stages[s].RunJobs {
+			for sp := range spawnInfos {
+				if spawnInfos[sp].WorkflowNodeJobRunID == nr.Stages[s].RunJobs[j].ID {
+					nr.Stages[s].RunJobs[j].SpawnInfos = append(nr.Stages[s].RunJobs[j].SpawnInfos, spawnInfos[sp])
+				}
 			}
-			rj.SpawnInfos = spawnInfos
 		}
 	}
 	return nil
diff --git a/engine/api/workflow/execute_node_run.go b/engine/api/workflow/execute_node_run.go
index 9b1378c12f..6c690961c2 100644
--- a/engine/api/workflow/execute_node_run.go
+++ b/engine/api/workflow/execute_node_run.go
@@ -428,7 +428,7 @@ func addJobsToQueue(ctx context.Context, db gorp.SqlExecutor, stage *sdk.Stage,
 		} else {
 			wjob.SpawnInfos = []sdk.SpawnInfo{sdk.SpawnInfo{
 				APITime:    time.Now(),
-				Message:    sdk.SpawnMsg{ID: sdk.MsgSpawnInfoJobInQueue.ID, Args: []interface{}{}},
+				Message:    sdk.SpawnMsg{ID: sdk.MsgSpawnInfoJobInQueue.ID},
 				RemoteTime: time.Now(),
 			}}
 		}
@@ -511,7 +511,7 @@ func syncStage(db gorp.SqlExecutor, store cache.Store, stage *sdk.Stage) (bool,
 			if runJobDB.Status == sdk.StatusBuilding.String() || runJobDB.Status == sdk.StatusWaiting.String() {
 				stageEnd = false
 			}
-			spawnInfos, err := loadNodeRunJobInfo(db, runJob.ID)
+			spawnInfos, err := loadNodeRunJobInfo(db, []int64{runJob.ID})
 			if err != nil {
 				return false, sdk.WrapError(err, "unable to load spawn infos for runJob: %d", runJob.ID)
 			}
@@ -846,7 +846,7 @@ func SyncNodeRunRunJob(ctx context.Context, db gorp.SqlExecutor, nodeRun *sdk.Wo
 		for j := range s.RunJobs {
 			runJob := &s.RunJobs[j]
 			if runJob.ID == nodeJobRun.ID {
-				spawnInfos, err := loadNodeRunJobInfo(db, runJob.ID)
+				spawnInfos, err := loadNodeRunJobInfo(db, []int64{runJob.ID})
 				if err != nil {
 					return false, sdk.WrapError(err, "unable to load spawn infos for runJobID: %d", runJob.ID)
 				}
diff --git a/sdk/build.go b/sdk/build.go
index ee0311aea0..8b9009109a 100644
--- a/sdk/build.go
+++ b/sdk/build.go
@@ -24,9 +24,10 @@ type PipelineBuildJob struct {
 
 // SpawnInfo contains an information about spawning
 type SpawnInfo struct {
-	APITime    time.Time `json:"api_time,omitempty" db:"-" mapstructure:"-"`
-	RemoteTime time.Time `json:"remote_time,omitempty" db:"-" mapstructure:"-"`
-	Message    SpawnMsg  `json:"message,omitempty" db:"-"`
+	WorkflowNodeJobRunID int64     `json:"WorkflowNodeJobRunID,omitempty" db:"-"`
+	APITime              time.Time `json:"api_time,omitempty" db:"-" mapstructure:"-"`
+	RemoteTime           time.Time `json:"remote_time,omitempty" db:"-" mapstructure:"-"`
+	Message              SpawnMsg  `json:"message,omitempty" db:"-"`
 	// UserMessage contains msg translated for end user
 	UserMessage string `json:"user_message,omitempty" db:"-"`
 }

From ceeb6336e9627fc2158120e7026eda9e1959e45f Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Fri, 30 Nov 2018 17:57:18 +0100
Subject: [PATCH 09/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_node_job_run_info.go | 6 +++++-
 engine/api/workflow/dao_node_run.go          | 3 +++
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/engine/api/workflow/dao_node_job_run_info.go b/engine/api/workflow/dao_node_job_run_info.go
index 5e2ff463d7..a2da05d7ab 100644
--- a/engine/api/workflow/dao_node_job_run_info.go
+++ b/engine/api/workflow/dao_node_job_run_info.go
@@ -35,7 +35,11 @@ func loadNodeRunJobInfo(db gorp.SqlExecutor, jobIDs []int64) ([]sdk.SpawnInfo, e
 	spawnInfos := []sdk.SpawnInfo{}
 	for i := range res {
 		spInfos := []sdk.SpawnInfo{}
-		gorpmapping.JSONNullString(res[i].Bytes, &spInfos)
+		if err := gorpmapping.JSONNullString(res[i].Bytes, &spInfos); err != nil {
+			// should never append, but log error
+			log.Warning("wrong spawnInfos format: res: %v for ids: %v", res[i].Bytes, ids[i])
+			continue
+		}
 		for i := range spInfos {
 			spInfos[i].WorkflowNodeJobRunID = res[i].WorkflowNodeJobRunID
 		}
diff --git a/engine/api/workflow/dao_node_run.go b/engine/api/workflow/dao_node_run.go
index 7779f82057..b8e412ee04 100644
--- a/engine/api/workflow/dao_node_run.go
+++ b/engine/api/workflow/dao_node_run.go
@@ -124,6 +124,7 @@ func LoadNodeRun(db gorp.SqlExecutor, projectkey, workflowname string, number, i
 }
 
 func loadSpawnInfos(db gorp.SqlExecutor, nr *sdk.WorkflowNodeRun) error {
+	// load all run job ids, used to get spawnInfos with only one SQL request
 	rjIds := make([]int64, 0)
 	for s := range nr.Stages {
 		for j := range nr.Stages[s].RunJobs {
@@ -131,11 +132,13 @@ func loadSpawnInfos(db gorp.SqlExecutor, nr *sdk.WorkflowNodeRun) error {
 		}
 	}
 
+	// load all spawnInfos for all jobs in the current node run
 	spawnInfos, err := loadNodeRunJobInfo(db, rjIds)
 	if err != nil {
 		return sdk.WrapError(err, "unable to load spawn infos")
 	}
 
+	// then reattach spawninfo to good node run job
 	for s := range nr.Stages {
 		for j := range nr.Stages[s].RunJobs {
 			for sp := range spawnInfos {

From f747d88178c792d98c3c251dacca7331de12d013 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Sat, 1 Dec 2018 00:09:18 +0100
Subject: [PATCH 10/17] refactor, get spawninfo as logs & service logs

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/api_routes.go                      |   1 +
 engine/api/workflow/dao_node_job_run_info.go  |  27 ++--
 engine/api/workflow/dao_node_run.go           |  33 -----
 engine/api/workflow/dao_run.go                |   1 -
 engine/api/workflow/execute_node_run.go       |   4 +-
 engine/api/workflow_run.go                    |  23 +++-
 engine/hatchery/swarm/swarm_util_create.go    |  53 ++++++++
 sdk/messages.go                               |   8 +-
 .../workflow/run/node/pipeline/pipeline.html  |  11 +-
 .../pipeline/spawninfo/spawninfo.component.ts | 123 +++++++++++++++---
 .../node/pipeline/spawninfo/spawninfo.html    |  15 ++-
 .../assets/worker/web/workflow-spawninfos.js  |  35 +++++
 12 files changed, 255 insertions(+), 79 deletions(-)
 create mode 100644 ui/src/assets/worker/web/workflow-spawninfos.js

diff --git a/engine/api/api_routes.go b/engine/api/api_routes.go
index 00eaee6b1e..841c7aa26b 100644
--- a/engine/api/api_routes.go
+++ b/engine/api/api_routes.go
@@ -261,6 +261,7 @@ func (api *API) InitRouter() {
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/runs/{number}/nodes/{nodeRunID}/stop", r.POSTEXECUTE(api.stopWorkflowNodeRunHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/runs/{number}/nodes/{nodeID}/history", r.GET(api.getWorkflowNodeRunHistoryHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/runs/{number}/{nodeName}/commits", r.GET(api.getWorkflowCommitsHandler))
+	r.Handle("/project/{key}/workflows/{permWorkflowName}/runs/{number}/nodes/{nodeRunID}/job/{runJobId}/info", r.GET(api.getWorkflowNodeRunJobSpawnInfosHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/runs/{number}/nodes/{nodeRunID}/job/{runJobId}/log/service", r.GET(api.getWorkflowNodeRunJobServiceLogsHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/runs/{number}/nodes/{nodeRunID}/job/{runJobId}/step/{stepOrder}", r.GET(api.getWorkflowNodeRunJobStepHandler))
 	r.Handle("/project/{key}/workflows/{permWorkflowName}/artifact/{artifactId}", r.GET(api.getDownloadArtifactHandler))
diff --git a/engine/api/workflow/dao_node_job_run_info.go b/engine/api/workflow/dao_node_job_run_info.go
index a2da05d7ab..70efd0654e 100644
--- a/engine/api/workflow/dao_node_job_run_info.go
+++ b/engine/api/workflow/dao_node_job_run_info.go
@@ -4,7 +4,7 @@ import (
 	"database/sql"
 	"encoding/json"
 	"fmt"
-	"strings"
+	"sort"
 	"time"
 
 	"github.com/go-gorp/gorp"
@@ -13,19 +13,13 @@ import (
 	"github.com/ovh/cds/sdk/log"
 )
 
-//loadNodeRunJobInfo load infos (workflow_node_run_job_infos) for a job (workflow_node_run_job)
-func loadNodeRunJobInfo(db gorp.SqlExecutor, jobIDs []int64) ([]sdk.SpawnInfo, error) {
-	ids := make([]string, len(jobIDs))
-	for i := range ids {
-		ids[i] = fmt.Sprintf("%d", jobIDs[i])
-	}
-	idsJoined := strings.Join(ids, ",")
+// LoadNodeRunJobInfo load infos (workflow_node_run_job_infos) for a job (workflow_node_run_job)
+func LoadNodeRunJobInfo(db gorp.SqlExecutor, jobID int64) ([]sdk.SpawnInfo, error) {
 	res := []struct {
-		Bytes                sql.NullString `db:"spawninfos"`
-		WorkflowNodeJobRunID int64          `db:"workflow_node_job_run_id"`
+		Bytes sql.NullString `db:"spawninfos"`
 	}{}
-	query := "SELECT workflow_node_run_job_id, spawninfos FROM workflow_node_run_job_info WHERE workflow_node_run_job_id = ANY(string_to_array($1, ',')::bigint[])"
-	if _, err := db.Select(&res, query, idsJoined); err != nil {
+	query := "SELECT workflow_node_run_job_id, spawninfos FROM workflow_node_run_job_info WHERE workflow_node_run_job_id = $1"
+	if _, err := db.Select(&res, query, jobID); err != nil {
 		if err == sql.ErrNoRows {
 			return nil, nil
 		}
@@ -37,14 +31,15 @@ func loadNodeRunJobInfo(db gorp.SqlExecutor, jobIDs []int64) ([]sdk.SpawnInfo, e
 		spInfos := []sdk.SpawnInfo{}
 		if err := gorpmapping.JSONNullString(res[i].Bytes, &spInfos); err != nil {
 			// should never append, but log error
-			log.Warning("wrong spawnInfos format: res: %v for ids: %v", res[i].Bytes, ids[i])
+			log.Warning("wrong spawnInfos format: res: %v for id: %v", res[i].Bytes, jobID)
 			continue
 		}
-		for i := range spInfos {
-			spInfos[i].WorkflowNodeJobRunID = res[i].WorkflowNodeJobRunID
-		}
 		spawnInfos = append(spawnInfos, spInfos...)
 	}
+	// sort here and not in sql, as it's could be a json array in sql value
+	sort.Slice(spawnInfos, func(i, j int) bool {
+		return spawnInfos[i].APITime.Before(spawnInfos[j].APITime)
+	})
 	return spawnInfos, nil
 }
 
diff --git a/engine/api/workflow/dao_node_run.go b/engine/api/workflow/dao_node_run.go
index b8e412ee04..2c735d67a5 100644
--- a/engine/api/workflow/dao_node_run.go
+++ b/engine/api/workflow/dao_node_run.go
@@ -107,11 +107,6 @@ func LoadNodeRun(db gorp.SqlExecutor, projectkey, workflowname string, number, i
 		}
 		r.Coverage = cov
 	}
-	if loadOpts.WithSpawnInfos {
-		if err := loadSpawnInfos(db, r); err != nil {
-			return nil, sdk.WrapError(err, "LoadNodeRun>Error load spawn infos %d", r.ID)
-		}
-	}
 	if loadOpts.WithVulnerabilities {
 		vuln, errV := loadVulnerabilityReport(db, r.ID)
 		if errV != nil && !sdk.ErrorIs(errV, sdk.ErrNotFound) {
@@ -123,34 +118,6 @@ func LoadNodeRun(db gorp.SqlExecutor, projectkey, workflowname string, number, i
 
 }
 
-func loadSpawnInfos(db gorp.SqlExecutor, nr *sdk.WorkflowNodeRun) error {
-	// load all run job ids, used to get spawnInfos with only one SQL request
-	rjIds := make([]int64, 0)
-	for s := range nr.Stages {
-		for j := range nr.Stages[s].RunJobs {
-			rjIds = append(rjIds, nr.Stages[s].RunJobs[j].ID)
-		}
-	}
-
-	// load all spawnInfos for all jobs in the current node run
-	spawnInfos, err := loadNodeRunJobInfo(db, rjIds)
-	if err != nil {
-		return sdk.WrapError(err, "unable to load spawn infos")
-	}
-
-	// then reattach spawninfo to good node run job
-	for s := range nr.Stages {
-		for j := range nr.Stages[s].RunJobs {
-			for sp := range spawnInfos {
-				if spawnInfos[sp].WorkflowNodeJobRunID == nr.Stages[s].RunJobs[j].ID {
-					nr.Stages[s].RunJobs[j].SpawnInfos = append(nr.Stages[s].RunJobs[j].SpawnInfos, spawnInfos[sp])
-				}
-			}
-		}
-	}
-	return nil
-}
-
 //LoadNodeRunByNodeJobID load a specific node run on a workflow from a node job run id
 func LoadNodeRunByNodeJobID(db gorp.SqlExecutor, nodeJobRunID int64, loadOpts LoadRunOptions) (*sdk.WorkflowNodeRun, error) {
 	var rr = NodeRun{}
diff --git a/engine/api/workflow/dao_run.go b/engine/api/workflow/dao_run.go
index 072c7ac984..419c2150d0 100644
--- a/engine/api/workflow/dao_run.go
+++ b/engine/api/workflow/dao_run.go
@@ -35,7 +35,6 @@ type LoadRunOptions struct {
 	WithCoverage            bool
 	WithArtifacts           bool
 	WithStaticFiles         bool
-	WithSpawnInfos          bool
 	WithTests               bool
 	WithLightTests          bool
 	WithVulnerabilities     bool
diff --git a/engine/api/workflow/execute_node_run.go b/engine/api/workflow/execute_node_run.go
index 6c690961c2..8b0512b066 100644
--- a/engine/api/workflow/execute_node_run.go
+++ b/engine/api/workflow/execute_node_run.go
@@ -511,7 +511,7 @@ func syncStage(db gorp.SqlExecutor, store cache.Store, stage *sdk.Stage) (bool,
 			if runJobDB.Status == sdk.StatusBuilding.String() || runJobDB.Status == sdk.StatusWaiting.String() {
 				stageEnd = false
 			}
-			spawnInfos, err := loadNodeRunJobInfo(db, []int64{runJob.ID})
+			spawnInfos, err := LoadNodeRunJobInfo(db, runJob.ID)
 			if err != nil {
 				return false, sdk.WrapError(err, "unable to load spawn infos for runJob: %d", runJob.ID)
 			}
@@ -846,7 +846,7 @@ func SyncNodeRunRunJob(ctx context.Context, db gorp.SqlExecutor, nodeRun *sdk.Wo
 		for j := range s.RunJobs {
 			runJob := &s.RunJobs[j]
 			if runJob.ID == nodeJobRun.ID {
-				spawnInfos, err := loadNodeRunJobInfo(db, []int64{runJob.ID})
+				spawnInfos, err := LoadNodeRunJobInfo(db, runJob.ID)
 				if err != nil {
 					return false, sdk.WrapError(err, "unable to load spawn infos for runJobID: %d", runJob.ID)
 				}
diff --git a/engine/api/workflow_run.go b/engine/api/workflow_run.go
index 24cfdbf20e..5b7a4dcc0a 100644
--- a/engine/api/workflow_run.go
+++ b/engine/api/workflow_run.go
@@ -764,7 +764,6 @@ func (api *API) getWorkflowNodeRunHandler() service.Handler {
 			WithStaticFiles:     true,
 			WithCoverage:        true,
 			WithVulnerabilities: true,
-			WithSpawnInfos:      true,
 		})
 		if err != nil {
 			return sdk.WrapError(err, "Unable to load last workflow run")
@@ -1345,6 +1344,28 @@ func (api *API) getWorkflowRunArtifactsHandler() service.Handler {
 	}
 }
 
+func (api *API) getWorkflowNodeRunJobSpawnInfosHandler() service.Handler {
+	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
+		runJobID, errJ := requestVarInt(r, "runJobId")
+		if errJ != nil {
+			return sdk.WrapError(errJ, "getWorkflowNodeRunJobSpawnInfosHandler> runJobId: invalid number")
+		}
+		db := api.mustDB()
+
+		spawnInfos, err := workflow.LoadNodeRunJobInfo(db, runJobID)
+		if err != nil {
+			return sdk.WrapError(err, "cannot load spawn infos for node run job id %d", runJobID)
+		}
+
+		l := r.Header.Get("Accept-Language")
+		for ki, info := range spawnInfos {
+			m := sdk.NewMessage(sdk.Messages[info.Message.ID], info.Message.Args...)
+			spawnInfos[ki].UserMessage = m.String(l)
+		}
+		return service.WriteJSON(w, spawnInfos, http.StatusOK)
+	}
+}
+
 func (api *API) getWorkflowNodeRunJobServiceLogsHandler() service.Handler {
 	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
 		runJobID, errJ := requestVarInt(r, "runJobId")
diff --git a/engine/hatchery/swarm/swarm_util_create.go b/engine/hatchery/swarm/swarm_util_create.go
index e8f5a95b26..a4cd1e1b7e 100644
--- a/engine/hatchery/swarm/swarm_util_create.go
+++ b/engine/hatchery/swarm/swarm_util_create.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"regexp"
 	"strings"
+	"time"
 
 	types "github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
@@ -119,12 +120,64 @@ checkImage:
 	}
 
 	if !imageFound {
+		infos := []sdk.SpawnInfo{
+			{
+				RemoteTime: time.Now(),
+				Message: sdk.SpawnMsg{
+					ID:   sdk.MsgSpawnInfoHatcheryStartDockerPull.ID,
+					Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image},
+				},
+			},
+		}
+
+		ctxtJobSendSpawnInfo, cancelJobSendSpawnInfo := context.WithTimeout(ctx, 10*time.Second)
+		if err := h.CDSClient().QueueJobSendSpawnInfo(ctxtJobSendSpawnInfo, spawnArgs.IsWorkflowJob, spawnArgs.JobID, infos); err != nil {
+			next()
+			log.Warning("spawnWorkerForJob> cannot client.QueueJobSendSpawnInfo for job %d: %s", spawnArgs.JobID, err)
+		}
+		cancelJobSendSpawnInfo()
+
 		_, next := observability.Span(ctx, "swarm.dockerClient.pullImage", observability.Tag("image", cArgs.image))
 		if err := h.pullImage(dockerClient, cArgs.image, timeoutPullImage); err != nil {
 			next()
+
+			infosEnd := []sdk.SpawnInfo{
+				{
+					RemoteTime: time.Now(),
+					Message: sdk.SpawnMsg{
+						ID:   sdk.MsgSpawnInfoHatcheryEndDockerPullErr.ID,
+						Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image, err},
+					},
+				},
+			}
+
+			ctxtJobSendSpawnInfoEndDockerPullErr, cancelJobSendSpawnInfoDockerPullErr := context.WithTimeout(ctx, 10*time.Second)
+			if err := h.CDSClient().QueueJobSendSpawnInfo(ctxtJobSendSpawnInfoEndDockerPullErr, spawnArgs.IsWorkflowJob, spawnArgs.JobID, infosEnd); err != nil {
+				next()
+				log.Warning("spawnWorkerForJob> cannot client.QueueJobSendSpawnInfo for job %d: %s", spawnArgs.JobID, err)
+			}
+			cancelJobSendSpawnInfoDockerPullErr()
+
 			return sdk.WrapError(err, "Unable to pull image %s on %s", cArgs.image, dockerClient.name)
 		}
 		next()
+
+		infosEnd := []sdk.SpawnInfo{
+			{
+				RemoteTime: time.Now(),
+				Message: sdk.SpawnMsg{
+					ID:   sdk.MsgSpawnInfoHatcheryEndDockerPull.ID,
+					Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image},
+				},
+			},
+		}
+
+		ctxtJobSendSpawnInfoEndDockerPull, cancelJobSendSpawnInfoDockerPull := context.WithTimeout(ctx, 10*time.Second)
+		if err := h.CDSClient().QueueJobSendSpawnInfo(ctxtJobSendSpawnInfoEndDockerPull, spawnArgs.IsWorkflowJob, spawnArgs.JobID, infosEnd); err != nil {
+			next()
+			log.Warning("spawnWorkerForJob> cannot client.QueueJobSendSpawnInfo for job %d: %s", spawnArgs.JobID, err)
+		}
+		cancelJobSendSpawnInfoDockerPull()
 	}
 
 	_, next = observability.Span(ctx, "swarm.dockerClient.ContainerCreate", observability.Tag(observability.TagWorker, cArgs.name), observability.Tag("network", fmt.Sprintf("%v", networkingConfig)))
diff --git a/sdk/messages.go b/sdk/messages.go
index 676eb823ab..17812890a3 100644
--- a/sdk/messages.go
+++ b/sdk/messages.go
@@ -59,7 +59,11 @@ var (
 	MsgPipelineJobUpdated                  = &Message{"MsgPipelineJobUpdated", trad{FR: "Le job %s du stage %s a été mis à jour", EN: "Job %s in stage %s updated"}, nil}
 	MsgPipelineJobAdded                    = &Message{"MsgPipelineJobAdded", trad{FR: "Le job %s du stage %s a été ajouté", EN: "Job %s in stage %s added"}, nil}
 	MsgPipelineJobDeleted                  = &Message{"MsgPipelineJobDeleted", trad{FR: "Le job %s du stage %s a été supprimé", EN: "Job %s in stage %s deleted"}, nil}
+	MsgSpawnInfoDeprecatedModel            = &Message{"MsgSpawnInfoDeprecatedModel", trad{FR: "Attention vous utilisez un worker model (%s) déprécié", EN: "Pay attention you are using a deprecated worker model (%s)"}, nil}
 	MsgSpawnInfoHatcheryStarts             = &Message{"MsgSpawnInfoHatcheryStarts", trad{FR: "La Hatchery %s (%s) a démarré le lancement du worker avec le modèle %s", EN: "Hatchery %s (%s) starts spawn worker with model %s"}, nil}
+	MsgSpawnInfoHatcheryStartDockerPull    = &Message{"MsgSpawnInfoHatcheryStartDockerPull", trad{FR: "La Hatchery %s (%s) a démarré le docker pull de l'image %s...", EN: "Hatchery %s (%s) starts docker pull %s..."}, nil}
+	MsgSpawnInfoHatcheryEndDockerPull      = &Message{"MsgSpawnInfoHatcheryEndDockerPull", trad{FR: "La Hatchery %s (%s) a terminé le docker pull de l'image %s", EN: "Hatchery %s (%s) docker pull %s done"}, nil}
+	MsgSpawnInfoHatcheryEndDockerPullErr   = &Message{"MsgSpawnInfoHatcheryEndDockerPullErr", trad{FR: "ATTENTION: La Hatchery %s (%s) a terminé le docker pull de l'image %s en erreur: %s", EN: "WARNING: Hatchery %s (%s) - docker pull %s done with error: %v"}, nil}
 	MsgSpawnInfoHatcheryErrorSpawn         = &Message{"MsgSpawnInfoHatcheryErrorSpawn", trad{FR: "Une erreur est survenue lorsque la Hatchery %s (%s) a démarré un worker avec le modèle %s après %s, err:%s", EN: "Error while Hatchery %s (%s) spawn worker with model %s after %s, err:%s"}, nil}
 	MsgSpawnInfoHatcheryStartsSuccessfully = &Message{"MsgSpawnInfoHatcheryStartsSuccessfully", trad{FR: "La Hatchery %s (%s) a démarré le worker %s avec succès en %s", EN: "Hatchery %s (%s) spawn worker %s successfully in %s"}, nil}
 	MsgSpawnInfoWorkerEnd                  = &Message{"MsgSpawnInfoWorkerEnd", trad{FR: "Le worker %s a terminé et a passé %s à travailler sur les étapes", EN: "Worker %s finished working on this job and took %s to work on the steps"}, nil}
@@ -78,7 +82,6 @@ var (
 	MsgWorkflowImportedInserted            = &Message{"MsgWorkflowImportedInserted", trad{FR: "Le workflow %s a été créé", EN: "Workflow %s has been created"}, nil}
 	MsgSpawnInfoHatcheryCannotStartJob     = &Message{"MsgSpawnInfoHatcheryCannotStart", trad{FR: "Aucune hatchery n'a pu démarrer de worker respectant vos pré-requis de job, merci de les vérifier.", EN: "No hatchery can spawn a worker corresponding your job's requirements. Please check your job's requirements."}, nil}
 	MsgWorkflowRunBranchDeleted            = &Message{"MsgWorkflowRunBranchDeleted", trad{FR: "La branche %s  a été supprimée", EN: "Branch %s has been deleted"}, nil}
-	MsgSpawnInfoDeprecatedModel            = &Message{"MsgSpawnInfoDeprecatedModel", trad{FR: "Attention vous utilisez un worker model (%s) déprécié", EN: "Pay attention you are using a deprecated worker model (%s)"}, nil}
 )
 
 // Messages contains all sdk Messages
@@ -125,6 +128,9 @@ var Messages = map[string]*Message{
 	MsgSpawnInfoHatcheryStarts.ID:             MsgSpawnInfoHatcheryStarts,
 	MsgSpawnInfoHatcheryErrorSpawn.ID:         MsgSpawnInfoHatcheryErrorSpawn,
 	MsgSpawnInfoHatcheryStartsSuccessfully.ID: MsgSpawnInfoHatcheryStartsSuccessfully,
+	MsgSpawnInfoHatcheryStartDockerPull.ID:    MsgSpawnInfoHatcheryStartDockerPull,
+	MsgSpawnInfoHatcheryEndDockerPull.ID:      MsgSpawnInfoHatcheryEndDockerPull,
+	MsgSpawnInfoHatcheryEndDockerPullErr.ID:   MsgSpawnInfoHatcheryEndDockerPullErr,
 	MsgSpawnInfoWorkerEnd.ID:                  MsgSpawnInfoWorkerEnd,
 	MsgSpawnInfoJobInQueue.ID:                 MsgSpawnInfoJobInQueue,
 	MsgSpawnInfoJobTaken.ID:                   MsgSpawnInfoJobTaken,
diff --git a/ui/src/app/views/workflow/run/node/pipeline/pipeline.html b/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
index 32dcf253c9..b0ed2b528f 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
+++ b/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
@@ -56,10 +56,13 @@
                     <ul>
                         <li>
                             <app-workflow-run-job-spawn-info
-                              [spawnInfos]="selectedRunJob.spawninfos"
-                              [variables]="selectedRunJob.parameters"
-                              [job]="selectedRunJob.job"
-                              [(displayServicesLogs)]="displayServiceLogs">
+                                [project]="project"
+                                [workflowName]="workflowName"
+                                [nodeRun]="nodeRun"
+                                [job]="selectedRunJob.job"
+                                [nodeJobRun]="selectedRunJob"
+                                [variables]="selectedRunJob.parameters"
+                                [(displayServicesLogs)]="displayServiceLogs">
                             </app-workflow-run-job-spawn-info>
                         </li>
                         <ng-container *ngIf="!displayServiceLogs">
diff --git a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
index abc809f389..2cda394da4 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
+++ b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
@@ -1,19 +1,42 @@
-import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
-import {TranslateService} from '@ngx-translate/core';
+import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
 import * as AU from 'ansi_up';
-import {Job} from '../../../../../../model/job.model';
-import {Parameter} from '../../../../../../model/parameter.model';
-import {SpawnInfo} from '../../../../../../model/pipeline.model';
-import {JobVariableComponent} from '../../../../../run/workflow/variables/job.variables.component';
+import { CDSWebWorker } from 'app/shared/worker/web.worker';
+import { Subscription } from 'rxjs';
+import { environment } from '../../../../../../../environments/environment';
+import { Job } from '../../../../../../model/job.model';
+import { Parameter } from '../../../../../../model/parameter.model';
+import { SpawnInfo } from '../../../../../../model/pipeline.model';
+import { PipelineStatus } from '../../../../../../model/pipeline.model';
+import { Project } from '../../../../../../model/project.model';
+import { WorkflowNodeJobRun, WorkflowNodeRun } from '../../../../../../model/workflow.run.model';
+import { AuthentificationStore } from '../../../../../../service/auth/authentification.store';
+import { JobVariableComponent } from '../../../../../run/workflow/variables/job.variables.component';
 
 @Component({
     selector: 'app-workflow-run-job-spawn-info',
     templateUrl: './spawninfo.html',
     styleUrls: ['./spawninfo.scss']
 })
-export class WorkflowRunJobSpawnInfoComponent {
+export class WorkflowRunJobSpawnInfoComponent implements OnInit, OnDestroy {
 
-    @Input() spawnInfos: Array<SpawnInfo>;
+    @Input() project: Project;
+    @Input() workflowName: string;
+    @Input() nodeRun: WorkflowNodeRun;
+    @Input('nodeJobRun')
+    set nodeJobRun(data: WorkflowNodeJobRun) {
+        if (data) {
+            this._nodeJobRun = data;
+            if (data.status === PipelineStatus.SUCCESS || data.status === PipelineStatus.FAIL || data.status === PipelineStatus.STOPPED) {
+                this.stopWorker();
+            }
+        }
+    }
+    get nodeJobRun(): WorkflowNodeJobRun {
+        return this._nodeJobRun;
+    }
+
+    spawnInfos: String;
     @Input() variables: Array<Parameter>;
     @Input('job')
     set job(data: Job) {
@@ -37,18 +60,34 @@ export class WorkflowRunJobSpawnInfoComponent {
     @ViewChild('jobVariable')
     jobVariable: JobVariableComponent;
 
+    _nodeJobRun: WorkflowNodeJobRun;
+
+    worker: CDSWebWorker;
+    workerSubscription: Subscription;
+
+    serviceSpawnInfos: Array<SpawnInfo>;
+    loading = true;
+
     show = true;
     displayServiceLogsLink = false;
     _job: Job;
     _displayServiceLogs: boolean;
     ansi_up = new AU.default;
 
-    constructor(private _translate: TranslateService) {}
+    ngOnDestroy(): void {
+        this.stopWorker();
+    }
+
+    ngOnInit(): void {
+        this.initWorker();
+    }
+
+    constructor(private _authStore: AuthentificationStore, private _translate: TranslateService) { }
 
     refreshDisplayServiceLogsLink() {
-      if (this.job && this.job.action && Array.isArray(this.job.action.requirements)) {
-          this.displayServiceLogsLink = this.job.action.requirements.some((req) => req.type === 'service');
-      }
+        if (this.job && this.job.action && Array.isArray(this.job.action.requirements)) {
+            this.displayServiceLogsLink = this.job.action.requirements.some((req) => req.type === 'service');
+        }
     }
 
     toggle() {
@@ -57,9 +96,9 @@ export class WorkflowRunJobSpawnInfoComponent {
 
     getSpawnInfos() {
         let msg = '';
-        if (this.spawnInfos) {
-            this.spawnInfos.forEach( s => {
-               msg += '[' + s.api_time.toString().substr(0, 19) + '] ' + s.user_message + '\n';
+        if (this.nodeJobRun.spawninfos) {
+            this.nodeJobRun.spawninfos.forEach(s => {
+                msg += '[' + s.api_time.toString().substr(0, 19) + '] ' + s.user_message + '\n';
             });
         }
         if (msg !== '') {
@@ -68,10 +107,62 @@ export class WorkflowRunJobSpawnInfoComponent {
         return this._translate.instant('job_spawn_no_information');
     }
 
+    initWorker(): void {
+        if (!this.serviceSpawnInfos) {
+            this.loading = true;
+        }
+
+        if (this.nodeJobRun.status !== PipelineStatus.WAITING && this.nodeJobRun.status !== PipelineStatus.BUILDING) {
+            this.spawnInfos = this.getSpawnInfos();
+            this.loading = false;
+            return;
+        }
+
+        if (!this.worker) {
+            this.worker = new CDSWebWorker('./assets/worker/web/workflow-spawninfos.js');
+            this.worker.start({
+                user: this._authStore.getUser(),
+                session: this._authStore.getSessionToken(),
+                api: environment.apiURL,
+                key: this.project.key,
+                workflowName: this.workflowName,
+                number: this.nodeRun.num,
+                nodeRunId: this.nodeRun.id,
+                runJobId: this.nodeJobRun.id,
+            });
+
+            this.workerSubscription = this.worker.response().subscribe(msg => {
+                if (msg) {
+                    let serviceSpawnInfos: Array<SpawnInfo> = JSON.parse(msg);
+                    if (this.loading) {
+                        this.loading = false;
+                    }
+                    let infos = '';
+                    serviceSpawnInfos.forEach(s => {
+                        infos += '[' + s.api_time.toString().substr(0, 19) + '] ' + s.user_message + '\n';
+                    });
+                    this.spawnInfos = this.ansi_up.ansi_to_html(infos);
+                    if (this.nodeJobRun.status === PipelineStatus.SUCCESS || this.nodeJobRun.status === PipelineStatus.FAIL ||
+                        this.nodeJobRun.status === PipelineStatus.STOPPED) {
+                        this.stopWorker();
+                        this.spawnInfos = this.getSpawnInfos();
+                    }
+                }
+            });
+        }
+    }
+
+    stopWorker() {
+        if (this.worker) {
+            this.worker.stop();
+            this.worker = null;
+        }
+    }
+
     openVariableModal(event: Event): void {
         event.stopPropagation();
         if (this.jobVariable) {
-            this.jobVariable.show({autofocus: false, closable: false, observeChanges: true});
+            this.jobVariable.show({ autofocus: false, closable: false, observeChanges: true });
         }
     }
 }
diff --git a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.html b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.html
index 7a56027ffd..2512c051b5 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.html
+++ b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.html
@@ -8,10 +8,10 @@
         </div>
         <div class="variables" *ngIf="displayServiceLogsLink">
             <a class="pointing" *ngIf="!displayServiceLogs" (click)="displayServiceLogs = true; $event.stopPropagation()">
-                 Display services logs
+                Display services logs
             </a>
             <a class="pointing" *ngIf="displayServiceLogs" (click)="displayServiceLogs = false; $event.stopPropagation()">
-                 Display job logs
+                Display job logs
             </a>
         </div>
         <div class="variables">
@@ -21,8 +21,13 @@
         </div>
     </div>
     <div class="spawnInfos" [hidden]="!show">
-        <pre [innerHTML]="getSpawnInfos()">
-        </pre>
+        <div class="log" *ngIf="!loading">
+            <div class="logs">
+                <pre [innerHTML]="spawnInfos">
+                </pre>
+            </div>
+        </div>
+        <div class="ui active centered inline loader" *ngIf="loading"></div>
     </div>
 </div>
-<app-workflow-run-job-variable [variables]="variables" #jobVariable></app-workflow-run-job-variable>
+<app-workflow-run-job-variable [variables]="variables" #jobVariable></app-workflow-run-job-variable>
\ No newline at end of file
diff --git a/ui/src/assets/worker/web/workflow-spawninfos.js b/ui/src/assets/worker/web/workflow-spawninfos.js
new file mode 100644
index 0000000000..77ed43bb84
--- /dev/null
+++ b/ui/src/assets/worker/web/workflow-spawninfos.js
@@ -0,0 +1,35 @@
+
+importScripts('../common.js');
+
+var started = false;
+var key = '';
+var workflowName = '';
+var number = 0;
+var nodeRunId = 0;
+var runJobId = 0;
+
+
+onmessage = function (e) {
+    key = e.data.key;
+    workflowName = e.data.workflowName;
+    number = e.data.number;
+    nodeRunId = e.data.nodeRunId;
+    runJobId = e.data.runJobId;
+
+    loadLog(e.data.user, e.data.session, e.data.api);
+};
+
+function loadLog (user, session, api) {
+    loop(4, function () {
+        var url = '/project/' + key + '/workflows/' + workflowName + '/runs/' + number + '/nodes/' + nodeRunId + '/job/' + runJobId + '/info';
+
+        var xhr = httpCall(url, api, user, session);
+        if (xhr.status >= 400) {
+            return true;
+        }
+        if (xhr.status === 200 && xhr.responseText !== null) {
+            postMessage(xhr.responseText);
+        }
+        return false;
+    });
+}

From 6b0a41192402b92088c5cf23e35641696eaf0f31 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Sat, 1 Dec 2018 00:11:01 +0100
Subject: [PATCH 11/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_node_job_run_info.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/engine/api/workflow/dao_node_job_run_info.go b/engine/api/workflow/dao_node_job_run_info.go
index 70efd0654e..0058263726 100644
--- a/engine/api/workflow/dao_node_job_run_info.go
+++ b/engine/api/workflow/dao_node_job_run_info.go
@@ -31,7 +31,7 @@ func LoadNodeRunJobInfo(db gorp.SqlExecutor, jobID int64) ([]sdk.SpawnInfo, erro
 		spInfos := []sdk.SpawnInfo{}
 		if err := gorpmapping.JSONNullString(res[i].Bytes, &spInfos); err != nil {
 			// should never append, but log error
-			log.Warning("wrong spawnInfos format: res: %v for id: %v", res[i].Bytes, jobID)
+			log.Warning("wrong spawnInfos format: res: %v for id: %v err: %v", res[i].Bytes, jobID, err)
 			continue
 		}
 		spawnInfos = append(spawnInfos, spInfos...)

From 86b2a226a78419f3c107bd1903b810a2ea96b92e Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Sat, 1 Dec 2018 00:12:20 +0100
Subject: [PATCH 12/17] import

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_node_job_run_info.go | 1 +
 engine/api/workflow/execute_node_run.go      | 1 +
 2 files changed, 2 insertions(+)

diff --git a/engine/api/workflow/dao_node_job_run_info.go b/engine/api/workflow/dao_node_job_run_info.go
index 0058263726..fe522eb960 100644
--- a/engine/api/workflow/dao_node_job_run_info.go
+++ b/engine/api/workflow/dao_node_job_run_info.go
@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/go-gorp/gorp"
+
 	"github.com/ovh/cds/engine/api/database/gorpmapping"
 	"github.com/ovh/cds/sdk"
 	"github.com/ovh/cds/sdk/log"
diff --git a/engine/api/workflow/execute_node_run.go b/engine/api/workflow/execute_node_run.go
index 8b0512b066..974c85bfc9 100644
--- a/engine/api/workflow/execute_node_run.go
+++ b/engine/api/workflow/execute_node_run.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/fsamin/go-dump"
 	"github.com/go-gorp/gorp"
+
 	"github.com/ovh/cds/engine/api/cache"
 	"github.com/ovh/cds/engine/api/group"
 	"github.com/ovh/cds/engine/api/observability"

From e127f66d6b2a5a07e7ebe23c8e548bbe5abcfe86 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Sat, 1 Dec 2018 15:32:22 +0100
Subject: [PATCH 13/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 sdk/messages.go                               | 10 ++--
 .../workflow/run/node/pipeline/pipeline.html  |  4 +-
 .../pipeline/spawninfo/spawninfo.component.ts | 51 ++++++++-----------
 3 files changed, 29 insertions(+), 36 deletions(-)

diff --git a/sdk/messages.go b/sdk/messages.go
index 17812890a3..694ddf12de 100644
--- a/sdk/messages.go
+++ b/sdk/messages.go
@@ -63,18 +63,18 @@ var (
 	MsgSpawnInfoHatcheryStarts             = &Message{"MsgSpawnInfoHatcheryStarts", trad{FR: "La Hatchery %s (%s) a démarré le lancement du worker avec le modèle %s", EN: "Hatchery %s (%s) starts spawn worker with model %s"}, nil}
 	MsgSpawnInfoHatcheryStartDockerPull    = &Message{"MsgSpawnInfoHatcheryStartDockerPull", trad{FR: "La Hatchery %s (%s) a démarré le docker pull de l'image %s...", EN: "Hatchery %s (%s) starts docker pull %s..."}, nil}
 	MsgSpawnInfoHatcheryEndDockerPull      = &Message{"MsgSpawnInfoHatcheryEndDockerPull", trad{FR: "La Hatchery %s (%s) a terminé le docker pull de l'image %s", EN: "Hatchery %s (%s) docker pull %s done"}, nil}
-	MsgSpawnInfoHatcheryEndDockerPullErr   = &Message{"MsgSpawnInfoHatcheryEndDockerPullErr", trad{FR: "ATTENTION: La Hatchery %s (%s) a terminé le docker pull de l'image %s en erreur: %s", EN: "WARNING: Hatchery %s (%s) - docker pull %s done with error: %v"}, nil}
+	MsgSpawnInfoHatcheryEndDockerPullErr   = &Message{"MsgSpawnInfoHatcheryEndDockerPullErr", trad{FR: "⚠ La Hatchery %s (%s) a terminé le docker pull de l'image %s en erreur: %s", EN: "⚠ Hatchery %s (%s) - docker pull %s done with error: %v"}, nil}
 	MsgSpawnInfoHatcheryErrorSpawn         = &Message{"MsgSpawnInfoHatcheryErrorSpawn", trad{FR: "Une erreur est survenue lorsque la Hatchery %s (%s) a démarré un worker avec le modèle %s après %s, err:%s", EN: "Error while Hatchery %s (%s) spawn worker with model %s after %s, err:%s"}, nil}
 	MsgSpawnInfoHatcheryStartsSuccessfully = &Message{"MsgSpawnInfoHatcheryStartsSuccessfully", trad{FR: "La Hatchery %s (%s) a démarré le worker %s avec succès en %s", EN: "Hatchery %s (%s) spawn worker %s successfully in %s"}, nil}
 	MsgSpawnInfoWorkerEnd                  = &Message{"MsgSpawnInfoWorkerEnd", trad{FR: "Le worker %s a terminé et a passé %s à travailler sur les étapes", EN: "Worker %s finished working on this job and took %s to work on the steps"}, nil}
-	MsgSpawnInfoJobInQueue                 = &Message{"MsgSpawnInfoJobInQueue", trad{FR: "Le job a été pris mis en file d'attente", EN: "Job was queued"}, nil}
+	MsgSpawnInfoJobInQueue                 = &Message{"MsgSpawnInfoJobInQueue", trad{FR: "✓ Le job a été pris mis en file d'attente", EN: "✓ Job was queued"}, nil}
 	MsgSpawnInfoJobTaken                   = &Message{"MsgSpawnInfoJobTaken", trad{FR: "Le job %s a été pris par le worker %s", EN: "Job %s was taken by worker %s"}, nil}
 	MsgSpawnInfoJobTakenWorkerVersion      = &Message{"MsgSpawnInfoJobTakenWorkerVersion", trad{FR: "Worker %s version:%s os:%s arch:%s", EN: "Worker %s version:%s os:%s arch:%s"}, nil}
 	MsgSpawnInfoWorkerForJob               = &Message{"MsgSpawnInfoWorkerForJob", trad{FR: "Ce worker %s a été créé pour lancer ce job", EN: "This worker %s was created to take this action"}, nil}
-	MsgSpawnInfoWorkerForJobError          = &Message{"MsgSpawnInfoWorkerForJobError", trad{FR: "Ce worker %s a été créé pour lancer ce job, mais ne possède pas tous les pré-requis. Vérifiez que les prérequis suivants:%s", EN: "This worker %s was created to take this action, but does not have all prerequisites. Please verify the following prerequisites:%s"}, nil}
-	MsgSpawnInfoJobError                   = &Message{"MsgSpawnInfoJobError", trad{FR: "Impossible de lancer ce job : %s", EN: "Unable to run this job: %s"}, nil}
+	MsgSpawnInfoWorkerForJobError          = &Message{"MsgSpawnInfoWorkerForJobError", trad{FR: "⚠ Ce worker %s a été créé pour lancer ce job, mais ne possède pas tous les pré-requis. Vérifiez que les prérequis suivants:%s", EN: "⚠ This worker %s was created to take this action, but does not have all prerequisites. Please verify the following prerequisites:%s"}, nil}
+	MsgSpawnInfoJobError                   = &Message{"MsgSpawnInfoJobError", trad{FR: "⚠ Impossible de lancer ce job : %s", EN: "⚠ Unable to run this job: %s"}, nil}
 	MsgWorkflowStarting                    = &Message{"MsgWorkflowStarting", trad{FR: "Le workflow %s#%s a été démarré", EN: "Workflow %s#%s has been started"}, nil}
-	MsgWorkflowError                       = &Message{"MsgWorkflowError", trad{FR: "Une erreur est survenue: %v", EN: "An error has occured: %v"}, nil}
+	MsgWorkflowError                       = &Message{"MsgWorkflowError", trad{FR: "⚠ Une erreur est survenue: %v", EN: "⚠ An error has occured: %v"}, nil}
 	MsgWorkflowNodeStop                    = &Message{"MsgWorkflowNodeStop", trad{FR: "Le pipeline a été arrété par %s", EN: "The pipeline has been stopped by %s"}, nil}
 	MsgWorkflowNodeMutex                   = &Message{"MsgWorkflowNodeMutex", trad{FR: "Le pipeline %s est mis en attente tant qu'il est en cours sur un autre run", EN: "The pipeline %s is waiting while it's running on another run"}, nil}
 	MsgWorkflowNodeMutexRelease            = &Message{"MsgWorkflowNodeMutexRelease", trad{FR: "Lancement du pipeline %s", EN: "Triggering pipeline %s"}, nil}
diff --git a/ui/src/app/views/workflow/run/node/pipeline/pipeline.html b/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
index b0ed2b528f..18356360ac 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
+++ b/ui/src/app/views/workflow/run/node/pipeline/pipeline.html
@@ -52,14 +52,14 @@
         </div>
         <div class="row">
             <div class="column">
-                <div class="log animated fadeIn" *ngIf="selectedRunJob && mapStepStatus">
+                <div class="log animated fadeIn" *ngIf="selectedRunJob && mapStepStatus && mapJobStatus">
                     <ul>
                         <li>
                             <app-workflow-run-job-spawn-info
                                 [project]="project"
                                 [workflowName]="workflowName"
                                 [nodeRun]="nodeRun"
-                                [job]="selectedRunJob.job"
+                                [jobStatus]="mapJobStatus.get(selectedRunJob.job.pipeline_action_id).status"
                                 [nodeJobRun]="selectedRunJob"
                                 [variables]="selectedRunJob.parameters"
                                 [(displayServicesLogs)]="displayServiceLogs">
diff --git a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
index 2cda394da4..24bf3d147b 100644
--- a/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
+++ b/ui/src/app/views/workflow/run/node/pipeline/spawninfo/spawninfo.component.ts
@@ -4,7 +4,6 @@ import * as AU from 'ansi_up';
 import { CDSWebWorker } from 'app/shared/worker/web.worker';
 import { Subscription } from 'rxjs';
 import { environment } from '../../../../../../../environments/environment';
-import { Job } from '../../../../../../model/job.model';
 import { Parameter } from '../../../../../../model/parameter.model';
 import { SpawnInfo } from '../../../../../../model/pipeline.model';
 import { PipelineStatus } from '../../../../../../model/pipeline.model';
@@ -22,12 +21,16 @@ export class WorkflowRunJobSpawnInfoComponent implements OnInit, OnDestroy {
 
     @Input() project: Project;
     @Input() workflowName: string;
+    @Input() jobStatus: string;
     @Input() nodeRun: WorkflowNodeRun;
     @Input('nodeJobRun')
     set nodeJobRun(data: WorkflowNodeJobRun) {
         if (data) {
             this._nodeJobRun = data;
-            if (data.status === PipelineStatus.SUCCESS || data.status === PipelineStatus.FAIL || data.status === PipelineStatus.STOPPED) {
+            this.refreshDisplayServiceLogsLink();
+            if (this.jobStatus === PipelineStatus.SUCCESS ||
+                this.jobStatus === PipelineStatus.FAIL ||
+                this.jobStatus === PipelineStatus.STOPPED) {
                 this.stopWorker();
             }
         }
@@ -38,14 +41,6 @@ export class WorkflowRunJobSpawnInfoComponent implements OnInit, OnDestroy {
 
     spawnInfos: String;
     @Input() variables: Array<Parameter>;
-    @Input('job')
-    set job(data: Job) {
-        this._job = data;
-        this.refreshDisplayServiceLogsLink();
-    }
-    get job(): Job {
-        return this._job
-    }
     @Input('displayServiceLogs')
     set displayServiceLogs(data: boolean) {
         this._displayServiceLogs = data;
@@ -70,7 +65,6 @@ export class WorkflowRunJobSpawnInfoComponent implements OnInit, OnDestroy {
 
     show = true;
     displayServiceLogsLink = false;
-    _job: Job;
     _displayServiceLogs: boolean;
     ansi_up = new AU.default;
 
@@ -85,8 +79,8 @@ export class WorkflowRunJobSpawnInfoComponent implements OnInit, OnDestroy {
     constructor(private _authStore: AuthentificationStore, private _translate: TranslateService) { }
 
     refreshDisplayServiceLogsLink() {
-        if (this.job && this.job.action && Array.isArray(this.job.action.requirements)) {
-            this.displayServiceLogsLink = this.job.action.requirements.some((req) => req.type === 'service');
+        if (this.nodeJobRun.job && this.nodeJobRun.job.action && Array.isArray(this.nodeJobRun.job.action.requirements)) {
+            this.displayServiceLogsLink = this.nodeJobRun.job.action.requirements.some((req) => req.type === 'service');
         }
     }
 
@@ -94,10 +88,11 @@ export class WorkflowRunJobSpawnInfoComponent implements OnInit, OnDestroy {
         this.show = !this.show;
     }
 
-    getSpawnInfos() {
+    getSpawnInfos(spawnInfosIn: Array<SpawnInfo>) {
+        this.loading = false;
         let msg = '';
-        if (this.nodeJobRun.spawninfos) {
-            this.nodeJobRun.spawninfos.forEach(s => {
+        if (spawnInfosIn) {
+            spawnInfosIn.forEach(s => {
                 msg += '[' + s.api_time.toString().substr(0, 19) + '] ' + s.user_message + '\n';
             });
         }
@@ -112,9 +107,10 @@ export class WorkflowRunJobSpawnInfoComponent implements OnInit, OnDestroy {
             this.loading = true;
         }
 
-        if (this.nodeJobRun.status !== PipelineStatus.WAITING && this.nodeJobRun.status !== PipelineStatus.BUILDING) {
-            this.spawnInfos = this.getSpawnInfos();
-            this.loading = false;
+        if (this.jobStatus !== PipelineStatus.WAITING && this.jobStatus !== PipelineStatus.BUILDING) {
+            if (this.nodeJobRun.spawninfos && this.nodeJobRun.spawninfos.length > 0) {
+                this.spawnInfos = this.getSpawnInfos(this.nodeJobRun.spawninfos);
+            }
             return;
         }
 
@@ -134,18 +130,15 @@ export class WorkflowRunJobSpawnInfoComponent implements OnInit, OnDestroy {
             this.workerSubscription = this.worker.response().subscribe(msg => {
                 if (msg) {
                     let serviceSpawnInfos: Array<SpawnInfo> = JSON.parse(msg);
-                    if (this.loading) {
-                        this.loading = false;
+                    if (serviceSpawnInfos && serviceSpawnInfos.length > 0) {
+                        this.spawnInfos = this.getSpawnInfos(serviceSpawnInfos);
                     }
-                    let infos = '';
-                    serviceSpawnInfos.forEach(s => {
-                        infos += '[' + s.api_time.toString().substr(0, 19) + '] ' + s.user_message + '\n';
-                    });
-                    this.spawnInfos = this.ansi_up.ansi_to_html(infos);
-                    if (this.nodeJobRun.status === PipelineStatus.SUCCESS || this.nodeJobRun.status === PipelineStatus.FAIL ||
-                        this.nodeJobRun.status === PipelineStatus.STOPPED) {
+                    if (this.jobStatus === PipelineStatus.SUCCESS || this.jobStatus === PipelineStatus.FAIL ||
+                        this.jobStatus === PipelineStatus.STOPPED) {
                         this.stopWorker();
-                        this.spawnInfos = this.getSpawnInfos();
+                        if (this.nodeJobRun.spawninfos && this.nodeJobRun.spawninfos.length > 0) {
+                            this.spawnInfos = this.getSpawnInfos(this.nodeJobRun.spawninfos);
+                        }
                     }
                 }
             });

From 6d5547e8deb489c9595e11ba746f2a128327a2e1 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Sun, 2 Dec 2018 18:21:56 +0100
Subject: [PATCH 14/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/hatchery/swarm/swarm_util_create.go | 63 +++++-----------------
 sdk/hatchery/hatchery.go                   | 10 ++++
 sdk/hatchery/starter.go                    | 57 +++++++-------------
 sdk/messages.go                            |  2 +-
 4 files changed, 41 insertions(+), 91 deletions(-)

diff --git a/engine/hatchery/swarm/swarm_util_create.go b/engine/hatchery/swarm/swarm_util_create.go
index a4cd1e1b7e..f37328265c 100644
--- a/engine/hatchery/swarm/swarm_util_create.go
+++ b/engine/hatchery/swarm/swarm_util_create.go
@@ -4,7 +4,6 @@ import (
 	"fmt"
 	"regexp"
 	"strings"
-	"time"
 
 	types "github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
@@ -120,64 +119,26 @@ checkImage:
 	}
 
 	if !imageFound {
-		infos := []sdk.SpawnInfo{
-			{
-				RemoteTime: time.Now(),
-				Message: sdk.SpawnMsg{
-					ID:   sdk.MsgSpawnInfoHatcheryStartDockerPull.ID,
-					Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image},
-				},
-			},
-		}
-
-		ctxtJobSendSpawnInfo, cancelJobSendSpawnInfo := context.WithTimeout(ctx, 10*time.Second)
-		if err := h.CDSClient().QueueJobSendSpawnInfo(ctxtJobSendSpawnInfo, spawnArgs.IsWorkflowJob, spawnArgs.JobID, infos); err != nil {
-			next()
-			log.Warning("spawnWorkerForJob> cannot client.QueueJobSendSpawnInfo for job %d: %s", spawnArgs.JobID, err)
-		}
-		cancelJobSendSpawnInfo()
+		hatchery.SendSpawnInfo(ctx, h, spawnArgs.IsWorkflowJob, spawnArgs.JobID, sdk.SpawnMsg{
+			ID:   sdk.MsgSpawnInfoHatcheryStartDockerPull.ID,
+			Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image},
+		})
 
 		_, next := observability.Span(ctx, "swarm.dockerClient.pullImage", observability.Tag("image", cArgs.image))
 		if err := h.pullImage(dockerClient, cArgs.image, timeoutPullImage); err != nil {
 			next()
-
-			infosEnd := []sdk.SpawnInfo{
-				{
-					RemoteTime: time.Now(),
-					Message: sdk.SpawnMsg{
-						ID:   sdk.MsgSpawnInfoHatcheryEndDockerPullErr.ID,
-						Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image, err},
-					},
-				},
-			}
-
-			ctxtJobSendSpawnInfoEndDockerPullErr, cancelJobSendSpawnInfoDockerPullErr := context.WithTimeout(ctx, 10*time.Second)
-			if err := h.CDSClient().QueueJobSendSpawnInfo(ctxtJobSendSpawnInfoEndDockerPullErr, spawnArgs.IsWorkflowJob, spawnArgs.JobID, infosEnd); err != nil {
-				next()
-				log.Warning("spawnWorkerForJob> cannot client.QueueJobSendSpawnInfo for job %d: %s", spawnArgs.JobID, err)
-			}
-			cancelJobSendSpawnInfoDockerPullErr()
-
+			hatchery.SendSpawnInfo(ctx, h, spawnArgs.IsWorkflowJob, spawnArgs.JobID, sdk.SpawnMsg{
+				ID:   sdk.MsgSpawnInfoHatcheryEndDockerPullErr.ID,
+				Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image, err},
+			})
 			return sdk.WrapError(err, "Unable to pull image %s on %s", cArgs.image, dockerClient.name)
 		}
 		next()
 
-		infosEnd := []sdk.SpawnInfo{
-			{
-				RemoteTime: time.Now(),
-				Message: sdk.SpawnMsg{
-					ID:   sdk.MsgSpawnInfoHatcheryEndDockerPull.ID,
-					Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image},
-				},
-			},
-		}
-
-		ctxtJobSendSpawnInfoEndDockerPull, cancelJobSendSpawnInfoDockerPull := context.WithTimeout(ctx, 10*time.Second)
-		if err := h.CDSClient().QueueJobSendSpawnInfo(ctxtJobSendSpawnInfoEndDockerPull, spawnArgs.IsWorkflowJob, spawnArgs.JobID, infosEnd); err != nil {
-			next()
-			log.Warning("spawnWorkerForJob> cannot client.QueueJobSendSpawnInfo for job %d: %s", spawnArgs.JobID, err)
-		}
-		cancelJobSendSpawnInfoDockerPull()
+		hatchery.SendSpawnInfo(ctx, h, spawnArgs.IsWorkflowJob, spawnArgs.JobID, sdk.SpawnMsg{
+			ID:   sdk.MsgSpawnInfoHatcheryEndDockerPull.ID,
+			Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), cArgs.image},
+		})
 	}
 
 	_, next = observability.Span(ctx, "swarm.dockerClient.ContainerCreate", observability.Tag(observability.TagWorker, cArgs.name), observability.Tag("network", fmt.Sprintf("%v", networkingConfig)))
diff --git a/sdk/hatchery/hatchery.go b/sdk/hatchery/hatchery.go
index 108645643b..0b47e02545 100644
--- a/sdk/hatchery/hatchery.go
+++ b/sdk/hatchery/hatchery.go
@@ -485,6 +485,16 @@ func canRunJob(h Interface, j workerStarterRequest, model sdk.Model) bool {
 	return h.CanSpawn(&model, j.id, j.requirements)
 }
 
+// SendSpawnInfo sends a spawnInfo
+func SendSpawnInfo(ctx context.Context, h Interface, isWorkflowJob bool, jobID int64, spawnMsg sdk.SpawnMsg) {
+	infos := []sdk.SpawnInfo{{RemoteTime: time.Now(), Message: spawnMsg}}
+	ctxc, cancel := context.WithTimeout(ctx, 10*time.Second)
+	if err := h.CDSClient().QueueJobSendSpawnInfo(ctxc, isWorkflowJob, jobID, infos); err != nil {
+		log.Warning("spawnWorkerForJob> cannot client.sendSpawnInfo for job %d: %s", jobID, err)
+	}
+	cancel()
+}
+
 func logTime(h Interface, name string, then time.Time) {
 	d := time.Since(then)
 	if d > time.Duration(h.Configuration().LogOptions.SpawnOptions.ThresholdCritical)*time.Second {
diff --git a/sdk/hatchery/starter.go b/sdk/hatchery/starter.go
index 3ba3416d7b..1483425f83 100644
--- a/sdk/hatchery/starter.go
+++ b/sdk/hatchery/starter.go
@@ -156,61 +156,40 @@ func spawnWorkerForJob(h Interface, j workerStarterRequest) (bool, error) {
 	log.Debug("hatchery> spawnWorkerForJob> %d - send book job %d %s by hatchery %d isWorkflowJob:%t", j.timestamp, j.id, j.model.Name, h.ID(), j.isWorkflowJob)
 
 	start := time.Now()
-	infos := []sdk.SpawnInfo{
-		{
-			RemoteTime: start,
-			Message:    sdk.SpawnMsg{ID: sdk.MsgSpawnInfoHatcheryStarts.ID, Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), j.model.Name}},
-		},
-	}
+	SendSpawnInfo(ctx, h, j.isWorkflowJob, j.id, sdk.SpawnMsg{
+		ID:   sdk.MsgSpawnInfoHatcheryStarts.ID,
+		Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), j.model.Name},
+	})
+
 	log.Info("hatchery> spawnWorkerForJob> SpawnWorker> starting model %s for job %d", j.model.Name, j.id)
 	_, next = observability.Span(ctx, "hatchery.SpawnWorker")
 	workerName, errSpawn := h.SpawnWorker(j.ctx, SpawnArguments{Model: j.model, IsWorkflowJob: j.isWorkflowJob, JobID: j.id, Requirements: j.requirements, LogInfo: "spawn for job"})
 	next()
 	if errSpawn != nil {
 		_, next = observability.Span(ctx, "hatchery.QueueJobSendSpawnInfo", observability.Tag("status", "errSpawn"))
-		log.Warning("spawnWorkerForJob> %d - cannot spawn worker %s for job %d: %s", j.timestamp, j.model.Name, j.id, errSpawn)
-		infos = append(infos, sdk.SpawnInfo{
-			RemoteTime: time.Now(),
-			Message:    sdk.SpawnMsg{ID: sdk.MsgSpawnInfoHatcheryErrorSpawn.ID, Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), j.model.Name, sdk.Round(time.Since(start), time.Second).String(), errSpawn.Error()}},
+		SendSpawnInfo(ctx, h, j.isWorkflowJob, j.id, sdk.SpawnMsg{
+			ID:   sdk.MsgSpawnInfoHatcheryErrorSpawn.ID,
+			Args: []interface{}{h.Service().Name, fmt.Sprintf("%d", h.ID()), j.model.Name, sdk.Round(time.Since(start), time.Second).String(), errSpawn.Error()},
 		})
-		ctxt, cancel := context.WithTimeout(ctx, 10*time.Second)
-		if err := h.CDSClient().QueueJobSendSpawnInfo(ctxt, j.isWorkflowJob, j.id, infos); err != nil {
-			log.Warning("spawnWorkerForJob> %d - cannot client.QueueJobSendSpawnInfo for job (err spawn)%d: %s", j.timestamp, j.id, err)
-		}
 		log.Error("hatchery %s cannot spawn worker %s for job %d: %v", h.Service().Name, j.model.Name, j.id, errSpawn)
 		next()
-		cancel()
 		return false, nil
 	}
 
-	infos = append(infos, sdk.SpawnInfo{
-		RemoteTime: time.Now(),
-		Message: sdk.SpawnMsg{ID: sdk.MsgSpawnInfoHatcheryStartsSuccessfully.ID,
-			Args: []interface{}{
-				h.Service().Name,
-				fmt.Sprintf("%d", h.ID()),
-				workerName,
-				sdk.Round(time.Since(start), time.Second).String()},
-		},
+	SendSpawnInfo(ctx, h, j.isWorkflowJob, j.id, sdk.SpawnMsg{
+		ID: sdk.MsgSpawnInfoHatcheryStartsSuccessfully.ID,
+		Args: []interface{}{
+			h.Service().Name,
+			fmt.Sprintf("%d", h.ID()),
+			workerName,
+			sdk.Round(time.Since(start), time.Second).String()},
 	})
 
 	if j.model.IsDeprecated {
-		infos = append(infos, sdk.SpawnInfo{
-			RemoteTime: time.Now(),
-			Message: sdk.SpawnMsg{
-				ID:   sdk.MsgSpawnInfoDeprecatedModel.ID,
-				Args: []interface{}{j.model.Name},
-			},
+		SendSpawnInfo(ctx, h, j.isWorkflowJob, j.id, sdk.SpawnMsg{
+			ID:   sdk.MsgSpawnInfoDeprecatedModel.ID,
+			Args: []interface{}{j.model.Name},
 		})
 	}
-
-	ctxtJobSendSpawnInfo, cancelJobSendSpawnInfo := context.WithTimeout(ctx, 10*time.Second)
-	_, next = observability.Span(ctx, "hatchery.QueueJobSendSpawnInfo", observability.Tag("status", "spawnOK"))
-	if err := h.CDSClient().QueueJobSendSpawnInfo(ctxtJobSendSpawnInfo, j.isWorkflowJob, j.id, infos); err != nil {
-		next()
-		log.Warning("spawnWorkerForJob> %d - cannot client.QueueJobSendSpawnInfo for job %d: %s", j.timestamp, j.id, err)
-	}
-	next()
-	cancelJobSendSpawnInfo()
 	return true, nil // ok for this job
 }
diff --git a/sdk/messages.go b/sdk/messages.go
index 694ddf12de..7b103f87cb 100644
--- a/sdk/messages.go
+++ b/sdk/messages.go
@@ -66,7 +66,7 @@ var (
 	MsgSpawnInfoHatcheryEndDockerPullErr   = &Message{"MsgSpawnInfoHatcheryEndDockerPullErr", trad{FR: "⚠ La Hatchery %s (%s) a terminé le docker pull de l'image %s en erreur: %s", EN: "⚠ Hatchery %s (%s) - docker pull %s done with error: %v"}, nil}
 	MsgSpawnInfoHatcheryErrorSpawn         = &Message{"MsgSpawnInfoHatcheryErrorSpawn", trad{FR: "Une erreur est survenue lorsque la Hatchery %s (%s) a démarré un worker avec le modèle %s après %s, err:%s", EN: "Error while Hatchery %s (%s) spawn worker with model %s after %s, err:%s"}, nil}
 	MsgSpawnInfoHatcheryStartsSuccessfully = &Message{"MsgSpawnInfoHatcheryStartsSuccessfully", trad{FR: "La Hatchery %s (%s) a démarré le worker %s avec succès en %s", EN: "Hatchery %s (%s) spawn worker %s successfully in %s"}, nil}
-	MsgSpawnInfoWorkerEnd                  = &Message{"MsgSpawnInfoWorkerEnd", trad{FR: "Le worker %s a terminé et a passé %s à travailler sur les étapes", EN: "Worker %s finished working on this job and took %s to work on the steps"}, nil}
+	MsgSpawnInfoWorkerEnd                  = &Message{"MsgSpawnInfoWorkerEnd", trad{FR: "✓ Le worker %s a terminé et a passé %s à travailler sur les étapes", EN: "✓ Worker %s finished working on this job and took %s to work on the steps"}, nil}
 	MsgSpawnInfoJobInQueue                 = &Message{"MsgSpawnInfoJobInQueue", trad{FR: "✓ Le job a été pris mis en file d'attente", EN: "✓ Job was queued"}, nil}
 	MsgSpawnInfoJobTaken                   = &Message{"MsgSpawnInfoJobTaken", trad{FR: "Le job %s a été pris par le worker %s", EN: "Job %s was taken by worker %s"}, nil}
 	MsgSpawnInfoJobTakenWorkerVersion      = &Message{"MsgSpawnInfoJobTakenWorkerVersion", trad{FR: "Worker %s version:%s os:%s arch:%s", EN: "Worker %s version:%s os:%s arch:%s"}, nil}

From bac6ed417d0add1603a0c1dc082d4340bee3b0eb Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Sun, 2 Dec 2018 18:25:13 +0100
Subject: [PATCH 15/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 sdk/build.go | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/sdk/build.go b/sdk/build.go
index 8b9009109a..ee0311aea0 100644
--- a/sdk/build.go
+++ b/sdk/build.go
@@ -24,10 +24,9 @@ type PipelineBuildJob struct {
 
 // SpawnInfo contains an information about spawning
 type SpawnInfo struct {
-	WorkflowNodeJobRunID int64     `json:"WorkflowNodeJobRunID,omitempty" db:"-"`
-	APITime              time.Time `json:"api_time,omitempty" db:"-" mapstructure:"-"`
-	RemoteTime           time.Time `json:"remote_time,omitempty" db:"-" mapstructure:"-"`
-	Message              SpawnMsg  `json:"message,omitempty" db:"-"`
+	APITime    time.Time `json:"api_time,omitempty" db:"-" mapstructure:"-"`
+	RemoteTime time.Time `json:"remote_time,omitempty" db:"-" mapstructure:"-"`
+	Message    SpawnMsg  `json:"message,omitempty" db:"-"`
 	// UserMessage contains msg translated for end user
 	UserMessage string `json:"user_message,omitempty" db:"-"`
 }

From 409df42f7d4532cc59c4c42aebc80ce98ae25f9b Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Sun, 2 Dec 2018 18:26:10 +0100
Subject: [PATCH 16/17] import

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_run.go              | 1 +
 engine/api/workflow/execute_node_job_run.go | 1 +
 2 files changed, 2 insertions(+)

diff --git a/engine/api/workflow/dao_run.go b/engine/api/workflow/dao_run.go
index 419c2150d0..8d3f2ec450 100644
--- a/engine/api/workflow/dao_run.go
+++ b/engine/api/workflow/dao_run.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/go-gorp/gorp"
+
 	"github.com/ovh/cds/engine/api/database/gorpmapping"
 	"github.com/ovh/cds/engine/api/observability"
 	"github.com/ovh/cds/sdk"
diff --git a/engine/api/workflow/execute_node_job_run.go b/engine/api/workflow/execute_node_job_run.go
index 29af29002d..a1615929d9 100644
--- a/engine/api/workflow/execute_node_job_run.go
+++ b/engine/api/workflow/execute_node_job_run.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/go-gorp/gorp"
 	"github.com/lib/pq"
+
 	"github.com/ovh/cds/engine/api/application"
 	"github.com/ovh/cds/engine/api/cache"
 	"github.com/ovh/cds/engine/api/environment"

From 346d3cb978b9a567dc86b07e7d4783d0fd3bb0c9 Mon Sep 17 00:00:00 2001
From: Yvonnick Esnault <yvonnick@esnau.lt>
Date: Sun, 2 Dec 2018 18:29:18 +0100
Subject: [PATCH 17/17] cr

Signed-off-by: Yvonnick Esnault <yvonnick@esnau.lt>
---
 engine/api/workflow/dao_node_job_run_info.go | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/engine/api/workflow/dao_node_job_run_info.go b/engine/api/workflow/dao_node_job_run_info.go
index fe522eb960..c2949e2419 100644
--- a/engine/api/workflow/dao_node_job_run_info.go
+++ b/engine/api/workflow/dao_node_job_run_info.go
@@ -19,7 +19,7 @@ func LoadNodeRunJobInfo(db gorp.SqlExecutor, jobID int64) ([]sdk.SpawnInfo, erro
 	res := []struct {
 		Bytes sql.NullString `db:"spawninfos"`
 	}{}
-	query := "SELECT workflow_node_run_job_id, spawninfos FROM workflow_node_run_job_info WHERE workflow_node_run_job_id = $1"
+	query := "SELECT spawninfos FROM workflow_node_run_job_info WHERE workflow_node_run_job_id = $1"
 	if _, err := db.Select(&res, query, jobID); err != nil {
 		if err == sql.ErrNoRows {
 			return nil, nil
@@ -60,6 +60,6 @@ func insertNodeRunJobInfo(db gorp.SqlExecutor, info *sdk.WorkflowNodeJobRunInfo)
 		return fmt.Errorf("insertNodeRunJobInfo> Unable to insert into workflow_node_run_job_info id = %d", info.WorkflowNodeJobRunID)
 	}
 
-	log.Debug("insertNodeRunJobInfo> on node run: %v (%d)", info.SpawnInfos, info.WorkflowNodeJobRunID)
+	log.Debug("insertNodeRunJobInfo> on node run: %d (job run:%d)", info.WorkflowNodeRunID, info.WorkflowNodeJobRunID)
 	return nil
 }