This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new a268c1cc0d feat(workflow-form): run the workflow from the form and 
show the author's instruction (#8440)
a268c1cc0d is described below

commit a268c1cc0d36ef9452fee54de6336483aefae17a
Author: yangzhang75 <[email protected]>
AuthorDate: Mon Sep 7 11:53:35 2026 +0000

    feat(workflow-form): run the workflow from the form and show the author's 
instruction (#8440)
    
    ### What changes were proposed in this PR?
    
    Closes #8023. Part of the Form View stack (parent issue #8011), stacked
    on #8438 (PR11) and #8437 (PR10).
    
    The Form View can now render inputs and write them back. This PR makes
    it usable end to end: it runs the workflow, and shows the author's
    instruction above the inputs.
    
    - **Run.** A Run button that shares the operator canvas's disable
    conditions (an invalid or empty workflow, a unit still connecting, or no
    unit chosen each disable it and say why), but deliberately simplifies
    the execution states a reader needs down to Run and Stop, with no
    pause/resume. The unit is chosen in the embedded computing-unit
    selector. Running is the same execution call the canvas makes, so empty
    or invalid inputs surface as a real engine error rather than a
    client-side gate; a run clock counts off the engine's own event, and a
    failed run collapses an opaque trace to one plain sentence (a required
    input left empty reads as "please fill in the required fields").
    - **Instruction.** The author's optional instruction renders above the
    inputs as a collapsible, read-only markdown card, shown only when there
    is text. It is bound through `[innerHTML]` with Angular's default
    sanitizer, so author markdown cannot inject script. Editing it is part
    of the authoring PR.
    
    Showing the chosen results is the next PR (#8024).
    
    ### Any related issues, documentation, discussions?
    
    Closes #8023. Part of the Form View feature (parent issue #8011).
    
    ### How was this PR tested?
    
    Unit tests (vitest). Direct-construction tests cover every run-button
    precedence branch, `onRun` (kill while running, no-op when disabled,
    else execute and clear the error), the five failed-run message paths,
    the run clock and its tick, and the status/validation/connection
    subscriptions; the instruction is covered for the markdown render,
    `hasInstruction`, and the toggle. TestBed template tests stand up the
    instruction card, the run bar, and the run-button click binding. 100%
    statement and function coverage on the changed source (the remaining
    uncovered branches are the pre-existing defensive `??`/`||` fallbacks).
    `ng test` (99 tests), `ng build gui`, eslint and prettier all pass, in
    both the single-user and collaboration paths.
    
    #### Screenshot
    
    The run bar (Run button + computing-unit selector + clock) and the
    author's instruction card above the inputs.
    <img width="1323" height="488" alt="Screenshot 2026-09-06 at 12 07
    57 PM"
    
src="https://github.com/user-attachments/assets/024d3bf5-be01-4449-8a0f-7538961cb83a";
    />
    
    
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Co-authored with Claude (Anthropic), reviewed line by line by the
    author before submission.
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Co-authored-by: Meng Wang <[email protected]>
---
 .../workflow-form/workflow-form.component.html     |  86 +++++-
 .../workflow-form/workflow-form.component.scss     | 173 ++++++++++++
 .../workflow-form/workflow-form.component.spec.ts  | 292 ++++++++++++++++++++-
 .../workflow-form/workflow-form.component.ts       | 290 +++++++++++++++++++-
 .../workflow-form/workflow-form.rendered.spec.ts   | 144 +++++++++-
 .../workflow-form/workflow-form.spec-harness.ts    |  47 +++-
 6 files changed, 1018 insertions(+), 14 deletions(-)

diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
index c924532d05..d0c0ce0f0e 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html
@@ -70,6 +70,39 @@
   </div>
 
   <div [hidden]="loading">
+    <!-- The author's one piece of guidance, shown as rendered markdown, 
collapsible, and only when
+         there is instruction text to show. Editing it is part of the 
authoring PR. -->
+    <section
+      class="card instr"
+      *ngIf="hasInstruction"
+      [class.open]="instructionOpen">
+      <button
+        type="button"
+        class="instr-bar"
+        [attr.aria-expanded]="instructionOpen"
+        (click)="toggleInstruction()">
+        <i
+          nz-icon
+          nzType="info-circle"
+          class="lead"
+          aria-hidden="true"></i>
+        <h2>{{ instructionTitle || "How to use this" }}</h2>
+        <i
+          nz-icon
+          nzType="down"
+          class="chev"
+          aria-hidden="true"></i>
+      </button>
+
+      <div
+        class="instr-body"
+        [hidden]="!instructionOpen">
+        <div
+          class="md"
+          [innerHTML]="instructionPreviewHtml"></div>
+      </div>
+    </section>
+
     <!-- The inputs an author exposed, each rendered as its operator's own 
field. -->
     <div class="pc-section-head">
       <span class="label">Inputs</span>
@@ -108,8 +141,57 @@
       </section>
     </div>
 
-    <!-- The workflow, out of the way unless the reader goes looking. Running 
and results are added
-         on top of this by the following PRs. -->
+    <!-- Run and the unit it runs on sit together, because one gates the 
other, but they stay two
+         controls: the selector draws its own bordered box. Adjacency and 
equal height say they
+         belong to each other. -->
+    <div class="runbar">
+      <div class="run-group">
+        <!-- One button in this page's own style. Its DISABLE conditions 
follow the operator canvas
+             (menu.component's getRunButtonBehavior) exactly -- disabled for 
an invalid or empty
+             graph, while a unit's socket comes up ("Connecting"), and before 
a unit is chosen
+             ("Connect") -- so the reader is never sent to press a button that 
does nothing. The
+             labels and icons are this page's own simplified Run/Stop set, 
shorter than the canvas's
+             ("Invalid" not "Invalid Workflow", a caret-right Run), and it 
drops the canvas's
+             Pause/Resume/Submitting: a run in flight simply shows "Stop". -->
+        <button
+          class="run"
+          [class.stop]="isRunning"
+          [class.connecting]="runButtonState.disabled && !isRunning"
+          [disabled]="runButtonState.disabled"
+          (click)="onRun()">
+          <i
+            nz-icon
+            [nzType]="runButtonState.icon"
+            aria-hidden="true"></i>
+          {{ runButtonState.label }}
+        </button>
+        <!-- The page opens its own connection on load, so this control no 
longer decides whether
+             running works by where it is mounted. -->
+        <div class="run-unit">
+          <texera-computing-unit-selection></texera-computing-unit-selection>
+        </div>
+        <!-- How long this has been going, from the same engine event the 
operator canvas counts. It
+             appears only once there is something to count, so a form at rest 
is not carrying a
+             0:00:00 around. -->
+        <span
+          class="run-clock"
+          *ngIf="executionDuration > 0">
+          {{ executionDuration | date: "H:mm:ss" : "UTC" }}
+        </span>
+      </div>
+    </div>
+    <!-- On its own line: a message that explains or blocks the run should not 
push the two controls
+         apart, and it is absent most of the time. -->
+    <p
+      class="run-note"
+      [class.err]="runError"
+      [attr.role]="runError ? 'alert' : 'status'"
+      *ngIf="runError || isRunning">
+      {{ runError || "Running -- this keeps going if you look away." }}
+    </p>
+
+    <!-- The workflow, out of the way unless the reader goes looking. Results 
are added on top of
+         this by the following PR. -->
     <section
       class="card wf"
       [class.open]="workflowOpen">
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
index 54e7ffd7bc..93ef44ad83 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss
@@ -190,6 +190,82 @@ $shell: #fafafa;
   background: #fff;
 }
 
+/* ---------- instruction ---------- */
+
+.instr {
+  margin-bottom: 26px;
+
+  .instr-bar {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    width: 100%;
+    padding: 13px 16px;
+    cursor: pointer;
+    user-select: none;
+    appearance: none;
+    border: 0;
+    background: none;
+    color: inherit;
+    font: inherit;
+    text-align: left;
+
+    h2 {
+      margin: 0;
+      font-size: 15px;
+      font-weight: 600;
+      flex: 1;
+    }
+
+    .lead {
+      color: $blue;
+    }
+
+    .chev {
+      color: $text-2;
+      transform: rotate(-90deg);
+      transition: transform 0.2s;
+    }
+  }
+
+  &.open .instr-bar .chev {
+    transform: rotate(0deg);
+  }
+
+  .instr-body {
+    border-top: 1px solid $divider;
+    padding: 16px;
+    /* A long explanation scrolls rather than pushing the form off screen, and 
can be dragged
+       taller by anyone who wants to read it all at once. */
+    max-height: 340px;
+    overflow-y: auto;
+    resize: vertical;
+  }
+}
+
+.md {
+  :first-child {
+    margin-top: 0;
+  }
+
+  :last-child {
+    margin-bottom: 0;
+  }
+
+  img {
+    max-width: 100%;
+    border: 1px solid $divider;
+    border-radius: 6px;
+  }
+
+  code {
+    background: $divider;
+    padding: 1px 6px;
+    border-radius: 4px;
+    font-size: 12.5px;
+  }
+}
+
 /* ---------- inputs ---------- */
 
 .pc-section-head {
@@ -311,6 +387,103 @@ $shell: #fafafa;
   }
 }
 
+/* ---------- run ---------- */
+
+.runbar {
+  display: flex;
+  align-items: center;
+  margin-top: 26px;
+}
+
+/* Run and its computing unit as one control. The seam between them is what 
says the two belong
+   together. */
+.run-group {
+  display: inline-flex;
+  align-items: center;
+  gap: 12px;
+
+  /* Quiet beside Run: it is something you glance at, not a control. Tabular 
figures stop the row
+     twitching as the seconds tick over. */
+  .run-clock {
+    align-self: center;
+    margin-left: 4px;
+    color: #5c6672;
+    font-size: 13px;
+    font-variant-numeric: tabular-nums;
+    letter-spacing: 0.02em;
+  }
+
+  .run-unit {
+    display: flex;
+    align-items: center;
+
+    /* The selector reserves 220px and right-aligns inside it, which on the 
toolbar keeps a row of
+       controls steady but here left a wide gap between Run and a box that 
belongs next to it. Let
+       it be as wide as its content. */
+    ::ng-deep .computing-units-selection {
+      min-width: 0;
+    }
+
+    /* Matched to Run's height so the pair reads as one row of controls. */
+    ::ng-deep .computing-units-dropdown-button {
+      height: 40px;
+    }
+  }
+}
+
+.run {
+  height: 40px;
+  // Fixed width, content centred, so the button is the same size in every 
state (Run / Stop /
+  // Connect / Connecting / Invalid / Empty) instead of shrinking to "Run" and 
jumping wide for the
+  // others. Sized to the longest label ("Connecting"); the disabled states 
are kept to one word so
+  // this stays compact.
+  min-width: 160px;
+  padding: 0 26px;
+  font-size: 15px;
+  font-weight: 600;
+  border-radius: 8px;
+  border: 1px solid $blue;
+  background: $blue;
+  color: #fff;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 9px;
+  cursor: pointer;
+
+  &:hover {
+    background: #40a9ff;
+    border-color: #40a9ff;
+  }
+
+  &.stop {
+    background: #fff;
+    color: #ff4d4f;
+    border-color: #ff4d4f;
+
+    &:hover {
+      background: #fff1f0;
+    }
+  }
+
+  &:disabled {
+    background: $shell;
+    border-color: $border;
+    color: rgba(0, 0, 0, 0.25);
+    cursor: not-allowed;
+  }
+}
+
+.run-note {
+  font-size: 13px;
+  color: $text-2;
+  margin: 9px 0 0;
+
+  &.err {
+    color: #ff4d4f;
+  }
+}
+
 /* ---------- workflow preview ---------- */
 
 .wf {
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
index 543eb94005..d3ba936e21 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { FormControl } from "@angular/forms";
+import { FormArray, FormControl, FormGroup, Validators } from "@angular/forms";
 import { Router } from "@angular/router";
 import { of, throwError } from "rxjs";
 
@@ -26,6 +26,8 @@ import { setupHarness, formViewWorkflow, resolved } from 
"./workflow-form.spec-h
 import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
 import { DefaultView } from 
"../../../dashboard/type/workflow-metadata.interface";
 import { FORM_DEBOUNCE_TIME_MS } from 
"../../service/execute-workflow/execute-workflow.service";
+import { ExecutionState } from "../../types/execute-workflow.interface";
+import { ComputingUnitState } from 
"../../../common/type/computing-unit-connection.interface";
 
 /**
  * These exercise the page's own decisions -- what a reader is shown, where an 
ordinary
@@ -56,14 +58,17 @@ describe("WorkflowFormComponent", () => {
       h.workflowResultService as any,
       h.notificationService as any,
       h.userService as any,
+      h.markdownService as any,
       h.formlyJsonschema as any,
       h.cdr as any,
       h.dynamicSchemaService as any,
       h.workflowCompilingService as any,
       h.computingUnitStatusService as any,
       h.workflowConsoleService as any,
+      h.workflowWebsocketService as any,
       h.host as any,
       h.datePipe as any,
+      h.validationWorkflowService as any,
       h.config as any
     );
     return component;
@@ -882,4 +887,289 @@ describe("WorkflowFormComponent", () => {
       document.body.removeChild(editable);
     });
   });
+
+  describe("the author's instruction", () => {
+    it("shows the instruction as rendered markdown when there is one", async 
() => {
+      formBindingService.getConfig.mockReturnValue({
+        instruction: { title: "Read me", body: "**bold**" },
+        fields: [],
+        resultOperatorIds: [],
+      });
+      build(formViewWorkflow).ngOnInit();
+      // renderInstruction resolves the parsed markdown on a microtask; let it 
settle.
+      await Promise.resolve();
+
+      expect(component.hasInstruction).toBe(true);
+      expect(component.instructionTitle).toBe("Read me");
+      // The markdown mock returns its input; the point is renderInstruction 
populated the html.
+      expect(component.instructionPreviewHtml).toBe("**bold**");
+    });
+
+    it("has no instruction when the body is blank", async () => {
+      formBindingService.getConfig.mockReturnValue({
+        instruction: { title: "T", body: "   " },
+        fields: [],
+        resultOperatorIds: [],
+      });
+      build(formViewWorkflow).ngOnInit();
+      await Promise.resolve();
+
+      expect(component.hasInstruction).toBe(false);
+      expect(component.instructionPreviewHtml).toBe("");
+    });
+
+    it("discards a stale instruction render when the body changed while 
parsing", async () => {
+      build(formViewWorkflow).ngOnInit();
+      (component as any).instructionBody = "first";
+      const pending = (component as any).renderInstruction();
+      // A newer readConfig sets a different body before the parse microtask 
resolves.
+      (component as any).instructionBody = "second";
+      await pending;
+
+      // The stale "first" result is dropped rather than overwriting the newer 
body's render.
+      expect(component.instructionPreviewHtml).not.toBe("first");
+    });
+
+    it("toggles the instruction open and closed", () => {
+      build(formViewWorkflow).ngOnInit();
+      expect(component.instructionOpen).toBe(true);
+
+      component.toggleInstruction();
+
+      expect(component.instructionOpen).toBe(false);
+    });
+  });
+
+  describe("the run button, mirroring the operator canvas", () => {
+    // Put the page in a ready-to-run state: a WRITE-access unit is up, the 
socket is connected, the
+    // graph valid.
+    const makeReady = () => {
+      h.workflowWebsocketService.isConnected = true;
+      h.statusStream.next(ComputingUnitState.Running);
+      (component as any).selectedUnit = { accessPrivilege: "WRITE" };
+      h.validationStream.next({ errors: {}, workflowEmpty: false });
+    };
+
+    it("stores the selected unit from the status service so Run can gate on 
write access", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      h.selectedUnitStream.next({ accessPrivilege: "WRITE" });
+
+      expect((component as any).selectedUnit).toEqual({ accessPrivilege: 
"WRITE" });
+    });
+
+    it("offers Connect before a unit is chosen", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      expect(component.runButtonState).toEqual({ label: "Connect", icon: 
"plus-circle", disabled: true });
+    });
+
+    it("offers Run once a unit is up and the graph is valid", () => {
+      build(formViewWorkflow).ngOnInit();
+      makeReady();
+
+      expect(component.runButtonState.label).toBe("Run");
+      expect(component.runButtonState.disabled).toBe(false);
+    });
+
+    it("shows Stop while running", () => {
+      build(formViewWorkflow).ngOnInit();
+      h.executionStateStream.next({ current: { state: ExecutionState.Running } 
});
+
+      expect(component.isRunning).toBe(true);
+      expect(component.runButtonState).toEqual({ label: "Stop", icon: "stop", 
disabled: false });
+    });
+
+    it("disables and says Invalid for a broken graph", () => {
+      build(formViewWorkflow).ngOnInit();
+      makeReady();
+      h.validationStream.next({ errors: { op: {} }, workflowEmpty: false });
+
+      expect(component.runButtonState).toEqual({ label: "Invalid", icon: 
"warning", disabled: true });
+    });
+
+    it("disables and says Empty for an empty graph", () => {
+      build(formViewWorkflow).ngOnInit();
+      makeReady();
+      h.validationStream.next({ errors: {}, workflowEmpty: true });
+
+      expect(component.runButtonState).toEqual({ label: "Empty", icon: 
"info-circle", disabled: true });
+    });
+
+    it("disables and says Connecting while the unit's socket comes up", () => {
+      build(formViewWorkflow).ngOnInit();
+      h.statusStream.next(ComputingUnitState.Running);
+      h.validationStream.next({ errors: {}, workflowEmpty: false });
+      h.workflowWebsocketService.isConnected = false;
+
+      expect(component.runButtonState).toEqual({ label: "Connecting", icon: 
"loading", disabled: true });
+    });
+
+    it("disables with No access when the chosen unit is shared read-only", () 
=> {
+      build(formViewWorkflow).ngOnInit();
+      h.workflowWebsocketService.isConnected = true;
+      h.statusStream.next(ComputingUnitState.Running);
+      h.validationStream.next({ errors: {}, workflowEmpty: false });
+      (component as any).selectedUnit = { accessPrivilege: "READ" };
+
+      expect(component.runButtonState).toEqual({ label: "No access", icon: 
"lock", disabled: true });
+    });
+
+    it("does not offer a dead Stop when the socket drops mid-run", () => {
+      build(formViewWorkflow).ngOnInit();
+      h.statusStream.next(ComputingUnitState.Running); // a unit is selected
+      h.executionStateStream.next({ current: { state: ExecutionState.Running } 
}); // a run is in flight
+      h.workflowWebsocketService.isConnected = false; // its socket drops
+
+      // Still "running", but the button must not offer a Stop that would kill 
through a dead socket.
+      expect(component.isRunning).toBe(true);
+      expect(component.runButtonState).toEqual({ label: "Connecting", icon: 
"loading", disabled: true });
+    });
+
+    it("repaints when the websocket connection status changes", () => {
+      build(formViewWorkflow).ngOnInit();
+      h.cdr.markForCheck.mockClear();
+
+      h.connectionStream.next(true);
+
+      expect(h.cdr.markForCheck).toHaveBeenCalled();
+    });
+  });
+
+  describe("running", () => {
+    const makeReady = () => {
+      h.workflowWebsocketService.isConnected = true;
+      h.statusStream.next(ComputingUnitState.Running);
+      (component as any).selectedUnit = { accessPrivilege: "WRITE" };
+      h.validationStream.next({ errors: {}, workflowEmpty: false });
+    };
+
+    it("runs the workflow with its name and clears any prior error", () => {
+      build(formViewWorkflow).ngOnInit();
+      makeReady();
+      component.runError = "old error";
+
+      component.onRun();
+
+      
expect(h.executeWorkflowService.executeWorkflow).toHaveBeenCalledWith("scGPT");
+      expect(component.runError).toBe("");
+    });
+
+    it("stops a running workflow instead of starting another", () => {
+      build(formViewWorkflow).ngOnInit();
+      h.executionStateStream.next({ current: { state: ExecutionState.Running } 
});
+
+      component.onRun();
+
+      expect(h.executeWorkflowService.killWorkflow).toHaveBeenCalled();
+      expect(h.executeWorkflowService.executeWorkflow).not.toHaveBeenCalled();
+    });
+
+    it("does nothing when the button is disabled", () => {
+      build(formViewWorkflow).ngOnInit();
+      // Default state is "Connect" (disabled): no unit chosen.
+
+      component.onRun();
+
+      expect(h.executeWorkflowService.executeWorkflow).not.toHaveBeenCalled();
+      expect(h.executeWorkflowService.killWorkflow).not.toHaveBeenCalled();
+    });
+
+    it("counts the run clock off the engine's duration event", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      h.durationEvents.next({ duration: 5000, isRunning: false });
+
+      expect(component.executionDuration).toBe(5000);
+    });
+
+    it("ticks the clock a second at a time while a run is going", () => {
+      vi.useFakeTimers();
+      build(formViewWorkflow).ngOnInit();
+
+      h.durationEvents.next({ duration: 1000, isRunning: true });
+      vi.advanceTimersByTime(1000);
+      vi.useRealTimers();
+
+      expect(component.executionDuration).toBe(2000);
+    });
+  });
+
+  describe("reporting a failed run", () => {
+    it("blames empty required inputs when a required field is left empty", () 
=> {
+      build(formViewWorkflow).ngOnInit();
+      const form = new FormGroup({ v: new FormControl("", Validators.required) 
});
+      component.rendered = [{ form } as any];
+
+      h.executionStateStream.next({ current: { state: ExecutionState.Failed, 
errorMessages: [{ message: "x" }] } });
+
+      expect(component.runError).toBe("Run failed: please fill in the required 
fields.");
+    });
+
+    it("finds a required error nested inside an array input", () => {
+      build(formViewWorkflow).ngOnInit();
+      const form = new FormGroup({ arr: new FormArray([new FormControl("", 
Validators.required)]) });
+      component.rendered = [{ form } as any];
+
+      h.executionStateStream.next({ current: { state: ExecutionState.Failed, 
errorMessages: [{ message: "x" }] } });
+
+      expect(component.runError).toBe("Run failed: please fill in the required 
fields.");
+    });
+
+    it("does not blame required fields for a non-required validation error", 
() => {
+      build(formViewWorkflow).ngOnInit();
+      // A pattern failure, not an empty required field: the reader gets the 
engine message, not
+      // "fill in the required fields".
+      const form = new FormGroup({ v: new FormControl("abc", 
Validators.pattern(/^\d+$/)) });
+      component.rendered = [{ form } as any];
+
+      h.executionStateStream.next({ current: { state: ExecutionState.Failed, 
errorMessages: [{ message: "boom" }] } });
+
+      expect(component.runError).toBe("Run failed: boom");
+    });
+
+    it("keeps a short human message, dropping the exception prefix", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      h.executionStateStream.next({
+        current: {
+          state: ExecutionState.Failed,
+          errorMessages: [{ message: "java.lang.RuntimeException: too many 
rows" }],
+        },
+      });
+
+      expect(component.runError).toBe("Run failed: too many rows");
+    });
+
+    it("collapses an opaque engine trace to a reload sentence", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      h.executionStateStream.next({
+        current: {
+          state: ExecutionState.Failed,
+          errorMessages: [{ message: "org.jooq.DataAccessException: SQL [..]" 
}],
+        },
+      });
+
+      expect(component.runError).toBe("Run failed -- please reload and try 
again.");
+    });
+
+    it("collapses an empty error message to the reload sentence too", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      h.executionStateStream.next({ current: { state: ExecutionState.Failed, 
errorMessages: [] } });
+
+      expect(component.runError).toBe("Run failed -- please reload and try 
again.");
+    });
+
+    it("gives a generic tail when the message cleans down to nothing", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      h.executionStateStream.next({
+        current: { state: ExecutionState.Failed, errorMessages: [{ message: 
"requirement failed: " }] },
+      });
+
+      expect(component.runError).toBe("Run failed: please check your inputs 
and try again.");
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
index 260f9c2303..f1070a7ccf 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -19,7 +19,7 @@
 
 import { ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, 
OnInit } from "@angular/core";
 import { CommonModule, DatePipe } from "@angular/common";
-import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms";
+import { AbstractControl, FormArray, FormGroup, FormsModule, 
ReactiveFormsModule } from "@angular/forms";
 import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
 import { FormlyJsonschema } from "@ngx-formly/core/json-schema";
 import { ActivatedRoute, Router } from "@angular/router";
@@ -28,12 +28,15 @@ import { NzAvatarModule } from "ng-zorro-antd/avatar";
 import { NzIconModule } from "ng-zorro-antd/icon";
 import { UserIconComponent } from 
"../../../dashboard/component/user/user-icon/user-icon.component";
 import { cloneDeep } from "lodash-es";
-import { forkJoin, Subject } from "rxjs";
-import { debounceTime, takeUntil } from "rxjs/operators";
+import { MarkdownService } from "ngx-markdown";
+import { EMPTY, forkJoin, Subject, timer } from "rxjs";
+import { debounceTime, switchMap, takeUntil, tap } from "rxjs/operators";
 
 import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant";
 import { FormFieldBinding, Workflow, WorkflowContent } from 
"../../../common/type/workflow";
 import { ComputingUnitStatusService } from 
"../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
+import { ComputingUnitState } from 
"../../../common/type/computing-unit-connection.interface";
+import { DashboardWorkflowComputingUnit } from 
"../../../common/type/workflow-computing-unit";
 import { WorkflowPersistService } from 
"../../../common/service/workflow-persist/workflow-persist.service";
 import { NotificationService } from 
"../../../common/service/notification/notification.service";
 import { UserService } from "../../../common/service/user/user.service";
@@ -44,10 +47,14 @@ import { ExecuteWorkflowService, FORM_DEBOUNCE_TIME_MS } 
from "../../service/exe
 import { OperatorMetadataService } from 
"../../service/operator-metadata/operator-metadata.service";
 import { FormBindingService, ResolvedField } from 
"../../service/form-binding/form-binding.service";
 import { WorkflowActionService } from 
"../../service/workflow-graph/model/workflow-action.service";
+import { ValidationWorkflowService } from 
"../../service/validation/validation-workflow.service";
 import { GuiConfigService } from "../../../common/service/gui-config.service";
 import { WorkflowConsoleService } from 
"../../service/workflow-console/workflow-console.service";
 import { WorkflowResultService } from 
"../../service/workflow-result/workflow-result.service";
+import { WorkflowWebsocketService } from 
"../../service/workflow-websocket/workflow-websocket.service";
+import { ExecutionState } from "../../types/execute-workflow.interface";
 import { Point } from "../../types/workflow-common.interface";
+import { ComputingUnitSelectionComponent } from 
"../power-button/computing-unit-selection.component";
 import { WorkflowEditorComponent } from 
"../workflow-editor/workflow-editor.component";
 import { MiniMapComponent } from 
"../workflow-editor/mini-map/mini-map.component";
 import { CoeditorUserIconComponent } from 
"../menu/coeditor-user-icon/coeditor-user-icon.component";
@@ -72,8 +79,10 @@ interface RenderedField {
  * operator's own formly field, so a file property gets the real picker and an 
attribute a column
  * dropdown -- and writes a filled-in value straight back to its operator, the 
same edit the canvas
  * makes, with each sub-field of a nested or repeated property renamed and 
hidden as the author set
- * it up. Running the workflow and showing results are added by later PRs. A 
view, not a new object:
- * it opens the same workflow the canvas does.
+ * it up. It also shows the author's instruction above the inputs, and runs 
the workflow: a Run
+ * button (a reader's simplified Run/Stop, sharing the canvas's disable 
conditions), the
+ * computing-unit selector, a run clock and plain-language failure messages. 
Showing the results is
+ * added by a later PR. A view, not a new object: it opens the same workflow 
the canvas does.
  */
 @UntilDestroy()
 @Component({
@@ -88,6 +97,7 @@ interface RenderedField {
     NzAvatarModule,
     NzIconModule,
     UserIconComponent,
+    ComputingUnitSelectionComponent,
     WorkflowEditorComponent,
     MiniMapComponent,
     CoeditorUserIconComponent,
@@ -108,6 +118,31 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
   /** Torn down and replaced whenever the form is rebuilt, so an old field's 
write-back stops. */
   private formsRebuilt = new Subject<void>();
 
+  /** The author's instruction, rendered above the inputs; the reader always 
sees it as markdown. */
+  public instructionOpen = true;
+  public instructionTitle = "";
+  public instructionBody = "";
+  public instructionPreviewHtml = "";
+
+  /**
+   * Milliseconds the current run has been going, counted from the same engine 
event the operator
+   * canvas counts: the engine reports the real elapsed time, and a local 1s 
timer fills in between
+   * reports so the display ticks instead of jumping.
+   */
+  public executionDuration = 0;
+  public executionState: ExecutionState = ExecutionState.Uninitialized;
+  public runError = "";
+  /** The picked unit's connection state, mirrored from the same stream the 
operator canvas reads,
+   *  so "Connecting" here means exactly what it means there. */
+  public computingUnitStatus: ComputingUnitState = 
ComputingUnitState.NoComputingUnit;
+  /** The picked unit itself, kept so Run can be gated on write access to it 
-- the same gate the
+   *  operator canvas applies (a READ/NONE-shared unit can be viewed but not 
executed on). */
+  private selectedUnit: DashboardWorkflowComputingUnit | null = null;
+  /** Workflow validity, read from the same validation stream the operator 
canvas uses, so Run is
+   *  disabled ("Invalid" / "Empty") in the same cases. */
+  public isWorkflowValid = true;
+  public isWorkflowEmpty = false;
+
   /** The collapsible workflow preview: closed until the reader opens it. */
   public workflowOpen = false;
   /** The embedded canvas is built the first time the strip opens, never while 
collapsed. */
@@ -137,6 +172,7 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     private workflowResultService: WorkflowResultService,
     private notificationService: NotificationService,
     private userService: UserService,
+    private markdownService: MarkdownService,
     private formlyJsonschema: FormlyJsonschema,
     private cdr: ChangeDetectorRef,
     // Injected for its side effect: it fills its map from the operator-add 
stream, so it has to
@@ -150,8 +186,12 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     private workflowCompilingService: WorkflowCompilingService,
     private computingUnitStatusService: ComputingUnitStatusService,
     private workflowConsoleService: WorkflowConsoleService,
+    private workflowWebsocketService: WorkflowWebsocketService,
     private host: ElementRef<HTMLElement>,
     private datePipe: DatePipe,
+    // Same source the operator canvas reads its "Invalid" / "Empty" states 
from, so Run is
+    // disabled here exactly when it is disabled there.
+    private validationWorkflowService: ValidationWorkflowService,
     private config: GuiConfigService
   ) {}
 
@@ -164,6 +204,77 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     this.wid = wid;
     this.load(wid);
 
+    // The run clock, reusing the operator canvas's source outright rather 
than timing anything
+    // here: the engine is the only thing that knows when the run really 
began, so a stopwatch
+    // started at the click would drift and would be wrong after a reload.
+    this.workflowWebsocketService
+      .subscribeToEvent("ExecutionDurationUpdateEvent")
+      .pipe(
+        tap(event => (this.executionDuration = event.duration)),
+        switchMap(event => (event.isRunning ? timer(1000, 1000) : EMPTY)),
+        untilDestroyed(this)
+      )
+      .subscribe(() => {
+        this.executionDuration += 1000;
+        this.cdr.markForCheck();
+      });
+
+    // The run button's state is read from getters, so a change in 
unit/connection/validity has to
+    // repaint the view. markForCheck, not detectChanges: a synchronous pass 
can be thrown out of by
+    // an unrelated component's NG0100, killing the subscription.
+    this.computingUnitStatusService
+      .getSelectedComputingUnit()
+      .pipe(untilDestroyed(this))
+      .subscribe(unit => {
+        this.selectedUnit = unit;
+        this.cdr.markForCheck();
+      });
+    this.computingUnitStatusService
+      .getStatus()
+      .pipe(untilDestroyed(this))
+      .subscribe(status => {
+        this.computingUnitStatus = status;
+        this.cdr.markForCheck();
+      });
+    this.workflowWebsocketService
+      .getConnectionStatusStream()
+      .pipe(untilDestroyed(this))
+      .subscribe(() => this.cdr.markForCheck());
+    // Validity from the canvas's own stream, so a broken graph disables Run 
("Invalid") here
+    // exactly as it does there.
+    this.validationWorkflowService
+      .getWorkflowValidationErrorStream()
+      .pipe(untilDestroyed(this))
+      .subscribe(value => {
+        this.isWorkflowEmpty = value.workflowEmpty;
+        this.isWorkflowValid = Object.keys(value.errors).length === 0;
+        this.cdr.markForCheck();
+      });
+
+    this.executeWorkflowService
+      .getExecutionStateStream()
+      .pipe(untilDestroyed(this))
+      .subscribe(({ current }) => {
+        this.executionState = current.state;
+        // Surface a failed run. Without this the spinner just stops and the 
form gives zero
+        // feedback -- the opposite of what a reader needs. onRun() clears 
runError before the next
+        // run, so a stale error never lingers.
+        if (current.state === ExecutionState.Failed) {
+          // A required input left empty is by far the commonest reason a run 
fails here, and the
+          // engine reports it as an opaque "... is not contained in the 
schema". Answer with the
+          // same word the field itself already shows ("required"), so the two 
messages are
+          // consistent -- and it covers every operator, not just this one.
+          this.runError = this.hasEmptyRequiredInputs()
+            ? "Run failed: please fill in the required fields."
+            : 
this.friendlyRunError(current.errorMessages?.[0]?.message?.trim() ?? "");
+        }
+        // markForCheck, not detectChanges: this is the one subscription the 
page cannot afford to
+        // lose. A synchronous detectChanges can be thrown out of by an 
unrelated component's NG0100,
+        // which would kill this stream and freeze the Run button on a stale 
state with no error
+        // shown; marking dirty and letting the next pass render avoids that.
+        this.cdr.markForCheck();
+      });
+
     // Attribute boxes become dropdowns only after compilation writes the 
column enums into each
     // operator's dynamic schema -- which lands after these cards were built. 
Rebuild on the
     // compilation-state stream, a ReplaySubject(1) so a late subscriber (this 
page reloads fresh
@@ -261,7 +372,12 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
   }
 
   private readConfig(): void {
+    const config = this.formBindingService.getConfig();
     this.parameters = this.formBindingService.resolveFields();
+    this.instructionTitle = config.instruction?.title ?? "";
+    this.instructionBody = config.instruction?.body ?? "";
+    // A reader always sees the instruction as rendered markdown.
+    void this.renderInstruction();
     this.buildForm();
   }
 
@@ -511,6 +627,170 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     return rendered.resolved.binding.id;
   }
 
+  // 
---------------------------------------------------------------------------
+  // Instruction: the author's one piece of guidance, shown as rendered 
markdown
+  // 
---------------------------------------------------------------------------
+
+  public get hasInstruction(): boolean {
+    return this.instructionBody.trim().length > 0;
+  }
+
+  private async renderInstruction(): Promise<void> {
+    // Capture the body this render is for: parsing can resolve on a later 
microtask, and a fresh
+    // readConfig() may start another render meanwhile. If the configured body 
changed while we were
+    // parsing, this result is stale -- drop it so the newer render's output 
stands.
+    const body = this.instructionBody;
+    const html = body.trim() ? await 
Promise.resolve(this.markdownService.parse(body)) : "";
+    if (body !== this.instructionBody) {
+      return;
+    }
+    this.instructionPreviewHtml = html;
+    this.cdr.detectChanges();
+  }
+
+  public toggleInstruction(): void {
+    this.instructionOpen = !this.instructionOpen;
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Running the same workflow the canvas runs, through the same execute/kill 
service. The canvas
+  // wraps its run with completion-email options 
(executeWorkflowWithEmailNotification); this page
+  // runs plainly (executeWorkflow), so a form-started run does not send that 
email.
+  // 
---------------------------------------------------------------------------
+
+  public get isRunning(): boolean {
+    return (
+      this.executionState !== ExecutionState.Uninitialized &&
+      this.executionState !== ExecutionState.Completed &&
+      this.executionState !== ExecutionState.Failed &&
+      this.executionState !== ExecutionState.Killed &&
+      this.executionState !== ExecutionState.Terminated
+    );
+  }
+
+  /**
+   * A unit is picked but its socket is still coming up -- the same window the 
operator canvas shows
+   * "Connecting" and disables its run button. Read from the exact condition 
the canvas uses
+   * (menu.component's getRunButtonBehavior), so the two stay in step.
+   */
+  public get isConnecting(): boolean {
+    return (
+      this.computingUnitStatus !== ComputingUnitState.NoComputingUnit && 
!this.workflowWebsocketService.isConnected
+    );
+  }
+
+  /** No unit chosen yet: the button shows a disabled "Connect" hint and the 
unit is picked in the
+   *  embedded selector -- unlike the canvas, where the Connect button is 
itself the click target. */
+  public get hasNoComputingUnit(): boolean {
+    return this.computingUnitStatus === ComputingUnitState.NoComputingUnit;
+  }
+
+  /** Write access to the chosen unit: the canvas gates execution on this (a 
READ/NONE-shared unit
+   *  can be selected and viewed but not run on), so the form must too, or a 
reader could execute on
+   *  a unit they only have read access to. */
+  public get hasUnitWriteAccess(): boolean {
+    return this.selectedUnit?.accessPrivilege === "WRITE";
+  }
+
+  /**
+   * The Run button's label, icon and disabled state. It shares the operator 
canvas's disable
+   * conditions -- an invalid or empty workflow, a unit still connecting, or 
no unit chosen each
+   * disable it and say why -- but deliberately simplifies the execution 
states a reader needs down
+   * to Run and Stop, with no pause/resume: while a run is in flight the 
button stops (kills) it,
+   * otherwise it runs. (The canvas offers Pause/Resume/Submitting and a 
clickable Connect; a form
+   * reader does not, and picks the unit in the embedded selector instead.)
+   */
+  public get runButtonState(): { label: string; icon: string; disabled: 
boolean } {
+    // Connecting is checked before Stop on purpose: if the socket drops 
mid-run, a "Stop" would
+    // send killWorkflow() through a dead socket and do nothing, so a 
disconnected unit disables the
+    // button (as the canvas does) rather than offering a kill that cannot be 
delivered.
+    if (this.isConnecting) {
+      return { label: "Connecting", icon: "loading", disabled: true };
+    }
+    if (this.isRunning) {
+      return { label: "Stop", icon: "stop", disabled: false };
+    }
+    if (!this.isWorkflowValid) {
+      return { label: "Invalid", icon: "warning", disabled: true };
+    }
+    if (this.isWorkflowEmpty) {
+      return { label: "Empty", icon: "info-circle", disabled: true };
+    }
+    if (this.hasNoComputingUnit) {
+      return { label: "Connect", icon: "plus-circle", disabled: true };
+    }
+    // A unit is chosen and connected, but shared to this reader read-only: 
the canvas gates
+    // execution on write access to the unit, so the form disables Run rather 
than sending a request
+    // that the unit would reject.
+    if (!this.hasUnitWriteAccess) {
+      return { label: "No access", icon: "lock", disabled: true };
+    }
+    return { label: "Run", icon: "caret-right", disabled: false };
+  }
+
+  public onRun(): void {
+    if (this.isRunning) {
+      this.executeWorkflowService.killWorkflow();
+      return;
+    }
+    // The button is disabled in exactly the states a run cannot start from 
(invalid/empty workflow,
+    // connecting, or no unit), so a stray call here would be a silent no-op.
+    if (this.runButtonState.disabled) {
+      return;
+    }
+    this.runError = "";
+    // Run as-is, like the canvas -- no client-side "fill everything first" 
gate (it diverged from
+    // the canvas and could not guarantee success anyway). Empty/invalid 
inputs surface as a real
+    // engine error via the execution-state stream (see the Failed handler).
+    this.executeWorkflowService.executeWorkflow(this.workflowName);
+  }
+
+  /**
+   * Turn an engine error into something a reader can act on: raw 
SQL/jOOQ/Java traces collapse to
+   * one plain sentence, a short human message is kept (minus any Java 
prefix). The full text is
+   * always logged for developers.
+   */
+  private friendlyRunError(raw: string): string {
+    if (raw) {
+      // eslint-disable-next-line no-console
+      console.error("[workflow-form] run failed:", raw);
+    }
+    const opaque =
+      !raw || /\bSQL \[|org\.jooq|org\.apache|org\.postgresql|foreign 
key|constraint|jdbc|\bat [\w.$]+\(/i.test(raw);
+    if (opaque) {
+      return "Run failed -- please reload and try again.";
+    }
+    const cleaned = raw
+      .replace(/^[\w.$]+(?:Exception|Error):\s*/, "")
+      .replace(/^requirement failed:\s*/i, "")
+      .trim();
+    return `Run failed: ${cleaned || "please check your inputs and try 
again."}`;
+  }
+
+  /**
+   * Whether any exposed input that is required is still empty. Reuses 
formly's own per-field
+   * required validation -- the very thing that renders "This field is 
required" under the box -- so
+   * the run-failure message stays consistent with the field hint.
+   */
+  private hasEmptyRequiredInputs(): boolean {
+    // Specifically a `required` error (a required field left empty), not just 
any invalid control:
+    // a pattern or range failure is a different problem and should not be 
answered with "fill in the
+    // required fields". Walk the control tree for a real required error.
+    const hasRequiredError = (control: AbstractControl): boolean => {
+      if (control.hasError("required")) {
+        return true;
+      }
+      if (control instanceof FormGroup) {
+        return Object.values(control.controls).some(hasRequiredError);
+      }
+      if (control instanceof FormArray) {
+        return control.controls.some(hasRequiredError);
+      }
+      return false;
+    };
+    return this.rendered.some(r => hasRequiredError(r.form));
+  }
+
   /** Open or close the workflow preview; opening it builds the canvas the 
first time. */
   public toggleWorkflow(): void {
     this.workflowOpen = !this.workflowOpen;
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
index d6e6a4cb93..bca2ee4e04 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
@@ -23,6 +23,17 @@ import { ComponentFixture, TestBed } from 
"@angular/core/testing";
 import { ActivatedRoute, Router } from "@angular/router";
 import { FormlyForm, FormlyModule } from "@ngx-formly/core";
 import { FormlyJsonschema } from "@ngx-formly/core/json-schema";
+import { NZ_ICONS } from "ng-zorro-antd/icon";
+import {
+  InfoCircleOutline,
+  DownOutline,
+  PlusCircleOutline,
+  CaretRightOutline,
+  StopOutline,
+  WarningOutline,
+  LoadingOutline,
+  LockOutline,
+} from "@ant-design/icons-angular/icons";
 import { EMPTY, of, Subject } from "rxjs";
 
 import { WorkflowFormComponent } from "./workflow-form.component";
@@ -39,8 +50,18 @@ import { ExecuteWorkflowService } from 
"../../service/execute-workflow/execute-w
 import { WorkflowResultService } from 
"../../service/workflow-result/workflow-result.service";
 import { NotificationService } from 
"../../../common/service/notification/notification.service";
 import { UserService } from "../../../common/service/user/user.service";
+import { MarkdownService } from "ngx-markdown";
 import { ComputingUnitStatusService } from 
"../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service";
 import { WorkflowConsoleService } from 
"../../service/workflow-console/workflow-console.service";
+import { WorkflowWebsocketService } from 
"../../service/workflow-websocket/workflow-websocket.service";
+import { ValidationWorkflowService } from 
"../../service/validation/validation-workflow.service";
+import { ComputingUnitSelectionComponent } from 
"../power-button/computing-unit-selection.component";
+import { WorkflowComputingUnitManagingService } from 
"../../../common/service/computing-unit/workflow-computing-unit/workflow-computing-unit-managing.service";
+import { WorkflowExecutionsService } from 
"../../../dashboard/service/user/workflow-executions/workflow-executions.service";
+import { ComputingUnitActionsService } from 
"../../../common/service/computing-unit/computing-unit-actions/computing-unit-actions.service";
+import { WorkflowPveService } from 
"../../service/virtual-environment/virtual-environment.service";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { ExecutionState } from "../../types/execute-workflow.interface";
 import { GuiConfigService } from "../../../common/service/gui-config.service";
 
 /**
@@ -71,6 +92,11 @@ describe("WorkflowFormComponent (rendered template)", () => {
     // the page's own inputs markup -- the section head, the empty state, the 
card and the form
     // wrapper -- rendered and covered.
     TestBed.overrideComponent(FormlyForm, { set: { template: "" } });
+    // Blank the computing-unit selector's own template (a child, not the 
page): its real markup
+    // needs a modal/executions/PVE service chain out of scope here. Blanking 
the child -- rather
+    // than overriding the page's imports, which would JIT-recompile the page 
and drop its
+    // host-binding coverage -- keeps the run bar around it rendered and the 
page fully covered.
+    TestBed.overrideComponent(ComputingUnitSelectionComponent, { set: { 
template: "" } });
     /* eslint-enable no-restricted-syntax */
 
     await TestBed.configureTestingModule({
@@ -118,7 +144,17 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
         { provide: OperatorMetadataService, useValue: { getOperatorMetadata: 
() => of({}) } },
         {
           provide: FormBindingService,
-          useValue: { resolveFields: () => [], readValue: () => undefined, 
writeValue: vi.fn() },
+          useValue: {
+            // An instruction so the instruction card renders and is covered.
+            getConfig: () => ({
+              instruction: { title: "How to use this", body: "Fill in the 
inputs." },
+              fields: [],
+              resultOperatorIds: [],
+            }),
+            resolveFields: () => [],
+            readValue: () => undefined,
+            writeValue: vi.fn(),
+          },
         },
         { provide: FormlyJsonschema, useValue: { toFieldConfig: () => ({ 
fieldGroup: [] }) } },
         { provide: DynamicSchemaService, useValue: { getDynamicSchema: () => 
({ jsonSchema: {} }) } },
@@ -126,13 +162,58 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
           provide: WorkflowCompilingService,
           useValue: { getCompilationStateInfoChangedStream: () => EMPTY },
         },
-        { provide: ExecuteWorkflowService, useValue: { 
resetExecutionAndWorkers: vi.fn() } },
+        {
+          provide: ExecuteWorkflowService,
+          useValue: {
+            getExecutionStateStream: () => EMPTY,
+            executeWorkflow: vi.fn(),
+            killWorkflow: vi.fn(),
+            resetExecutionAndWorkers: vi.fn(),
+          },
+        },
         { provide: WorkflowResultService, useValue: { clearResults: vi.fn() } 
},
         { provide: NotificationService, useValue: { error: vi.fn() } },
         { provide: UserService, useValue: { getCurrentUser: () => undefined, 
isLogin: () => false } },
-        { provide: ComputingUnitStatusService, useValue: { disconnect: vi.fn() 
} },
+        { provide: MarkdownService, useValue: { parse: (s: string) => s } },
+        {
+          provide: ComputingUnitStatusService,
+          useValue: {
+            disconnect: vi.fn(),
+            getSelectedComputingUnit: () => EMPTY,
+            getStatus: () => EMPTY,
+            // Read by the (blanked) computing-unit selector's own ngOnInit.
+            getAllComputingUnits: () => EMPTY,
+          },
+        },
+        // The blanked computing-unit selector still constructs and runs 
ngOnInit; give it the few
+        // services it reads so it does not throw. It renders nothing (its 
template is blanked).
+        { provide: WorkflowComputingUnitManagingService, useValue: { 
getComputingUnitLimitOptions: () => EMPTY } },
+        { provide: WorkflowExecutionsService, useValue: {} },
+        { provide: ComputingUnitActionsService, useValue: {} },
+        { provide: WorkflowPveService, useValue: {} },
+        { provide: NzModalService, useValue: {} },
         { provide: WorkflowConsoleService, useValue: { clearConsoleMessages: 
vi.fn() } },
+        {
+          provide: WorkflowWebsocketService,
+          useValue: { subscribeToEvent: () => EMPTY, isConnected: true, 
getConnectionStatusStream: () => EMPTY },
+        },
+        { provide: ValidationWorkflowService, useValue: { 
getWorkflowValidationErrorStream: () => EMPTY } },
         { provide: GuiConfigService, useValue: { env: { formViewEnabled: true 
} } },
+        // Register the icons the run bar and instruction use, so nz-icon 
renders them inline instead
+        // of fetching each SVG over HTTP (an unresolved fetch that would hang 
fixture.whenStable).
+        {
+          provide: NZ_ICONS,
+          useValue: [
+            InfoCircleOutline,
+            DownOutline,
+            PlusCircleOutline,
+            CaretRightOutline,
+            StopOutline,
+            WarningOutline,
+            LoadingOutline,
+            LockOutline,
+          ],
+        },
         DatePipe,
       ],
     }).compileComponents();
@@ -253,6 +334,63 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
     expect(el(".param.read-only")).not.toBeNull();
   });
 
+  it("renders the author's instruction card and toggles it", async () => {
+    fixture.detectChanges();
+    finishLoad();
+    // renderInstruction resolves the markdown on a microtask.
+    await fixture.whenStable();
+    fixture.detectChanges();
+
+    expect(el(".card.instr")).not.toBeNull();
+    expect(el(".instr .instr-bar h2")?.textContent?.trim()).toBe("How to use 
this");
+    expect(el(".instr .md")?.innerHTML).toContain("Fill in the inputs.");
+
+    (el(".instr-bar") as HTMLButtonElement).click();
+    expect(fixture.componentInstance.instructionOpen).toBe(false);
+  });
+
+  it("renders the run bar with the run button and the computing-unit 
selector", () => {
+    fixture.detectChanges();
+    finishLoad();
+
+    expect(el(".runbar .run")).not.toBeNull();
+    // Default state: no unit chosen, so the button reads Connect and is 
disabled.
+    expect(el(".runbar .run")?.textContent?.trim()).toContain("Connect");
+    expect((el(".runbar .run") as HTMLButtonElement).disabled).toBe(true);
+    expect(el(".runbar texera-computing-unit-selection")).not.toBeNull();
+    // At rest there is nothing to count and no run note.
+    expect(el(".run-clock")).toBeNull();
+    expect(el(".run-note")).toBeNull();
+  });
+
+  it("fires onRun when the enabled run button is clicked", () => {
+    fixture.detectChanges();
+    finishLoad();
+    // A running state makes the button "Stop" (enabled); a disabled button 
would swallow the click.
+    fixture.componentInstance.executionState = ExecutionState.Running;
+    fixture.detectChanges();
+    const run = vi.spyOn(fixture.componentInstance, 
"onRun").mockImplementation(() => {});
+
+    el(".runbar .run")!.click();
+
+    expect(run).toHaveBeenCalled();
+  });
+
+  it("announces a run failure as an alert and a running note as a status", () 
=> {
+    fixture.detectChanges();
+    finishLoad();
+    const c = fixture.componentInstance;
+
+    c.runError = "Run failed: boom";
+    fixture.detectChanges();
+    expect(el(".run-note")?.getAttribute("role")).toBe("alert");
+
+    c.runError = "";
+    c.executionState = ExecutionState.Running;
+    fixture.detectChanges();
+    expect(el(".run-note")?.getAttribute("role")).toBe("status");
+  });
+
   it("tears the workflow down when the browser unloads (the beforeunload host 
binding)", () => {
     fixture.detectChanges();
     finishLoad();
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
index 842cbea78d..4a9b7fa605 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts
@@ -56,6 +56,15 @@ export function setupHarness() {
   const workflowMetaDataChangedStream = new Subject<unknown>();
   // Compilation reports column names late; the form rebuilds its inputs off 
this stream.
   const compilationChanged = new Subject<unknown>();
+  // Run-related streams the run tests drive: execution state, the engine's 
duration event, the
+  // computing-unit connection status, the workflow validity, and the 
websocket connection.
+  const executionStateStream = new Subject<any>();
+  const durationEvents = new Subject<{ duration: number; isRunning: boolean 
}>();
+  const statusStream = new Subject<any>();
+  // The picked computing unit (with its accessPrivilege), separate from the 
connection status.
+  const selectedUnitStream = new Subject<any>();
+  const validationStream = new Subject<{ errors: Record<string, unknown>; 
workflowEmpty: boolean }>();
+  const connectionStream = new Subject<boolean>();
   // The operators the graph holds: `hasOperatorIds` gates operatorSchemaFor, 
`graphOperators`
   // supplies each operator's type (which picks the custom widget). Tests add 
to them as needed.
   const hasOperatorIds = new Set<string>();
@@ -87,6 +96,9 @@ export function setupHarness() {
   // Resolves the exposed inputs and reads/writes their values. Tests point 
`resolveFields` at the
   // inputs they want rendered; `readValue` seeds the write-back guard.
   const formBindingService = {
+    // The presentation config: the instruction plus the fields. Tests 
override getConfig to give an
+    // instruction; resolveFields drives which inputs render.
+    getConfig: vi.fn().mockReturnValue({ instruction: undefined, fields: [], 
resultOperatorIds: [] }),
     resolveFields: vi.fn().mockReturnValue([]),
     readValue: vi.fn().mockReturnValue(undefined),
     writeValue: vi.fn(),
@@ -160,14 +172,34 @@ export function setupHarness() {
   const coeditorPresenceService = { coeditors: [] };
   const route = { snapshot: { params: { id: "7" } } };
   const operatorMetadataService = { getOperatorMetadata: () => of({}) };
-  const executeWorkflowService = { resetExecutionAndWorkers: vi.fn() };
+  const executeWorkflowService = {
+    getExecutionStateStream: () => executionStateStream.asObservable(),
+    executeWorkflow: vi.fn(),
+    killWorkflow: vi.fn(),
+    resetExecutionAndWorkers: vi.fn(),
+  };
   const workflowResultService = { clearResults: vi.fn() };
   const notificationService = { error: vi.fn() };
   // Not logged in by default so opening a workflow does not save; the save 
tests log in.
   const userService = { getCurrentUser: () => undefined, isLogin: 
vi.fn().mockReturnValue(false) };
-  const cdr = { detectChanges: vi.fn() };
-  const computingUnitStatusService = { disconnect: vi.fn() };
+  const markdownService = { parse: (s: string) => s };
+  const cdr = { detectChanges: vi.fn(), markForCheck: vi.fn() };
+  const computingUnitStatusService = {
+    disconnect: vi.fn(),
+    getSelectedComputingUnit: () => selectedUnitStream.asObservable(),
+    getStatus: () => statusStream.asObservable(),
+  };
   const workflowConsoleService = { clearConsoleMessages: vi.fn() };
+  // The websocket the run clock and the "Connecting" state read. 
`isConnected` is a plain settable
+  // flag so a test can put the page in the connecting window.
+  const workflowWebsocketService = {
+    subscribeToEvent: (_: string) => durationEvents.asObservable(),
+    isConnected: true,
+    getConnectionStatusStream: () => connectionStream.asObservable(),
+  };
+  const validationWorkflowService = {
+    getWorkflowValidationErrorStream: () => validationStream.asObservable(),
+  };
   // The name field is measured off the host; querySelector returns null so 
the measuring
   // (DOM-layout, jsdom has none) short-circuits. `contains` drives 
isTypingInTheForm; false by
   // default so a rebuild is never suppressed, and overridden by the tests 
that probe typing.
@@ -195,18 +227,27 @@ export function setupHarness() {
     workflowResultService,
     notificationService,
     userService,
+    markdownService,
     formlyJsonschema,
     cdr,
     dynamicSchemaService,
     workflowCompilingService,
     computingUnitStatusService,
     workflowConsoleService,
+    workflowWebsocketService,
+    validationWorkflowService,
     host,
     datePipe,
     config,
     workflowChangedStream,
     workflowMetaDataChangedStream,
     compilationChanged,
+    executionStateStream,
+    durationEvents,
+    statusStream,
+    selectedUnitStream,
+    validationStream,
+    connectionStream,
     hasOperatorIds,
     graphOperators,
     triggerCenterEvent,

Reply via email to