diff --git a/client/src/components/Workflow.vue b/client/src/components/Workflow.vue
index 695d8440e..2411e62ee 100644
--- a/client/src/components/Workflow.vue
+++ b/client/src/components/Workflow.vue
@@ -23,15 +23,24 @@
{{ projectJson !== null ? projectJson.name : "" }}
-
- status: {{ projectState }}{{ isReadOnly }}
-
+
+
+ status: {{ projectState }}{{ isReadOnly }}
+
+
+ Failed tasks:
{{ failedTaskPaths.join('\n') }}
+
0) {
+ return "#E60000";
+ }
return state2color(this.projectState);
},
readOnlyColor() {
@@ -497,7 +509,10 @@ export default {
return this.selectedSourceFilenames[0].filename;
},
runProjectAllowed() {
- return isAllowed(this.projectState, "runProject");
+ return isAllowed(this.projectState, "runProject") && !this.readOnly;
+ },
+ forceEditAllowed() {
+ return this.readOnly && ["stopped", "finished", "failed", "unknown"].includes(this.projectState);
},
pauseProjectAllowed() {
return isAllowed(this.projectState, "pauseProject");
@@ -516,6 +531,9 @@ export default {
},
cleanProjectAllowed() {
return isAllowed(this.projectState, "cleanProject");
+ },
+ failedTaskPaths() {
+ return this.projectJson?.failedTasks ?? [];
}
},
mounted: function () {
diff --git a/common/allowedOperations.cjs b/common/allowedOperations.cjs
index a52c51316..315bc3f52 100644
--- a/common/allowedOperations.cjs
+++ b/common/allowedOperations.cjs
@@ -9,11 +9,11 @@ const allowedOperations = {
"not-started": ["runProject", "revertProject", "saveProject", "checkProject"],
"preparing": ["cleanProject"],
"running": ["stopProject"],
- "stopped": ["cleanProject"],
- "finished": ["cleanProject"],
- "failed": ["cleanProject"],
+ "stopped": ["runProject", "cleanProject"],
+ "finished": ["runProject", "cleanProject"],
+ "failed": ["runProject", "cleanProject"],
"holding": [],
- "unknown": ["cleanProject"],
+ "unknown": ["runProject", "cleanProject"],
};
module.exports = allowedOperations;
diff --git a/documentMD/user_guide/_reference/3_workflow_screen/1_graphview.md b/documentMD/user_guide/_reference/3_workflow_screen/1_graphview.md
index b15cedbf3..e42bc488b 100644
--- a/documentMD/user_guide/_reference/3_workflow_screen/1_graphview.md
+++ b/documentMD/user_guide/_reference/3_workflow_screen/1_graphview.md
@@ -58,6 +58,39 @@ WHEELから投入されたジョブの実行状態確認時に、規定の回数
ルートワークフローから順に下位コンポーネントへと移動して、unknown状態となっているタスクコンポーネントを探し、当該ジョブの実行状態がワークフロー全体の実行の成否に影響が無いかを確認してください。
{: .notice--info}
+### プロジェクト操作ボタンエリア(`stopped`/`failed`/`unknown`状態からの再実行について)
+
+プロジェクトが `stopped`、`failed`、または `unknown` 状態のとき、run project ボタンを押すと中断した時点の状態から実行を再開(リスタート)することができます。
+
+#### 各コンポーネントの挙動
+
+リスタート時には、各コンポーネントの状態に応じて以下のように処理されます。
+
+| コンポーネントの状態 | リスタート時の挙動 |
+|:---------------------|:---------------------------------|
+| `finished` | スキップ(再実行されません) |
+| `not-started` | 通常通り実行されます |
+| `running` | 実行中に中断されたため再実行されます |
+| `failed` | 再実行されます |
+
+#### ループコンポーネント(For / Foreach / While)のリスタート挙動
+
+ループコンポーネントが実行途中で中断された場合、リスタートすると完了済みのイテレーション(`finished` 状態のインスタンス)はスキップされ、中断された時点のインデックスから実行を再開します。
+
+#### PSコンポーネントのリスタート挙動
+
+PSコンポーネントが実行途中で中断された場合、リスタートすると完了済み(`finished`)のインスタンスはスキップされ、未完了のインスタンスのみが再実行されます。
+
+#### 保存(save)してからリスタートする場合との違い
+
+`stop project` 後に `save project` を行ってからリスタートすると、全てのコンポーネントの状態が `not-started` にリセットされ、ワークフローが最初から実行されます。
+保存せずに直接 `run project` を押した場合は、中断した時点の状態から再開します(ループコンポーネントは完了済みのイテレーションをスキップして続きから実行)。
+
+#### cleanup project との違い
+
+`cleanup project` ボタンはプロジェクトの実行中に生成されたファイルを全て削除し、コンポーネントの状態を `not-started` にリセットして最初から実行できる状態に戻します。
+リスタートはファイルを削除せず中断時の状態を引き継ぎますが、`cleanup project` はゼロから実行し直す場合に使用します。
+
### プロジェクト操作ボタンエリア
このエリアには、プロジェクトの実行に関わるボタンが表示されます。
@@ -65,8 +98,8 @@ WHEELから投入されたジョブの実行状態確認時に、規定の回数
||構成要素|説明|
|----------|----------|---------------------------------|
-|1|run project ボタン |プロジェクトを実行開始します|
-|2|stop project ボタン |プロジェクトの実行を停止し実行前の状態に戻します|
+|1|run project ボタン |プロジェクトを実行開始します。`stopped`、`failed`、`unknown` 状態のプロジェクトは、中断した時点の状態から実行を再開(リスタート)します。詳細は[上記のリスタートについての説明](#プロジェクト操作ボタンエリアstoppedfailedunknown状態からの再実行について)を参照してください|
+|2|stop project ボタン |プロジェクトの実行を停止し、`stopped` 状態に移行します。各コンポーネントの実行状態はそのまま保持されます|
|3|cleanup project ボタン |プロジェクトの実行中に生成されたファイルなどを削除し、実行開始前の状態に戻します|
### 保存ボタンエリア
diff --git a/documentMD/user_guide/_reference/4_component/03_For.md b/documentMD/user_guide/_reference/4_component/03_For.md
index 1c83e47b3..ddcdb309a 100644
--- a/documentMD/user_guide/_reference/4_component/03_For.md
+++ b/documentMD/user_guide/_reference/4_component/03_For.md
@@ -51,5 +51,11 @@ Forコンポーネントが初めて実行されるとき、コンポーネン
number of instance to keepの値が0以外に設定されていた場合は、4,7の処理を実行後に設定された数を超える古いディレクトリ(`for_1`や`for_3`など)を削除します。
+### 中断時のリスタート挙動
+
+プロジェクトが `stopped`、`failed`、または `unknown` 状態で中断された後にリスタートすると、Forコンポーネントは完了済みのイテレーション(`finished` 状態のインスタンスディレクトリ)をスキップし、中断された時点のインデックスから実行を再開します。
+
+リスタート前に `save project` を行なった場合は、全コンポーネントの状態が `not-started` にリセットされ、最初のインデックスから実行し直します。
+
--------
[コンポーネントの詳細に戻る]({{ site.baseurl }}/reference/4_component/)
diff --git a/documentMD/user_guide/_reference/4_component/04_while.md b/documentMD/user_guide/_reference/4_component/04_while.md
index 5d1d5fa26..0b8cf7038 100644
--- a/documentMD/user_guide/_reference/4_component/04_while.md
+++ b/documentMD/user_guide/_reference/4_component/04_while.md
@@ -35,6 +35,12 @@ Whileコンポーネントも、Forコンポーネントと同様の挙動をし
また終了判定もインデックス値の計算ではなく、設定されたシェルスクリプトの戻り値か、javascript式の評価結果を用います。
+### 中断時のリスタート挙動
+
+プロジェクトが `stopped`、`failed`、または `unknown` 状態で中断された後にリスタートすると、Whileコンポーネントは完了済みのイテレーション(`finished` 状態のインスタンスディレクトリ)をスキップし、中断された時点のインデックスから実行を再開します。
+
+リスタート前に `save project` を行なった場合は、全コンポーネントの状態が `not-started` にリセットされ、最初のインデックス(0)から実行し直します。
+
--------
[コンポーネントの詳細に戻る]({{ site.baseurl }}/reference/4_component/)
diff --git a/documentMD/user_guide/_reference/4_component/05_Foreach.md b/documentMD/user_guide/_reference/4_component/05_Foreach.md
index 013a7d422..f189df525 100644
--- a/documentMD/user_guide/_reference/4_component/05_Foreach.md
+++ b/documentMD/user_guide/_reference/4_component/05_Foreach.md
@@ -31,6 +31,12 @@ __インデックス値の参照方法について__
ForeachコンポーネントもForコンポーネントと同様の挙動をしますがインデックス値は計算によって求められるのではなく、indexListに設定された値がリストの先頭から順に使われます。
リストの終端まで実行されるとコンポーネント全体の実行を終了します。
+### 中断時のリスタート挙動
+
+プロジェクトが `stopped`、`failed`、または `unknown` 状態で中断された後にリスタートすると、Foreachコンポーネントは完了済みのイテレーション(`finished` 状態のインスタンスディレクトリ)をスキップし、中断された時点のインデックスから実行を再開します。
+
+リスタート前に `save project` を行なった場合は、全コンポーネントの状態が `not-started` にリセットされ、indexListの先頭から実行し直します。
+
--------
[コンポーネントの詳細に戻る]({{ site.baseurl }}/reference/4_component/)
diff --git a/documentMD/user_guide/_reference/4_component/06_PS.md b/documentMD/user_guide/_reference/4_component/06_PS.md
index ce331c513..e6f5df5d4 100644
--- a/documentMD/user_guide/_reference/4_component/06_PS.md
+++ b/documentMD/user_guide/_reference/4_component/06_PS.md
@@ -23,6 +23,12 @@ PSコンポーネントは実行開始時に[パラメータ設定ファイル](
このときもパラメータの値を用いてファイル名を変更することができます。
アプリケーションの実行結果ファイルなど、同じ名前のファイルが作成されている場合はこの機能を用いてリネームし、集めてください。
+### 中断時のリスタート挙動
+
+プロジェクトが `stopped`、`failed`、または `unknown` 状態で中断された後にリスタートすると、PSコンポーネントは完了済み(`finished`)のインスタンスディレクトリをスキップし、未完了のインスタンスのみを再実行します。
+
+リスタート前に `save project` を行なった場合は、全コンポーネントの状態が `not-started` にリセットされ、全パラメータの組み合わせについて最初から実行し直します。
+
### parameterFile

diff --git a/server/app/core/componentJsonIO.js b/server/app/core/componentJsonIO.js
index 89e345bae..0f079798f 100644
--- a/server/app/core/componentJsonIO.js
+++ b/server/app/core/componentJsonIO.js
@@ -18,7 +18,7 @@ const { readJsonGreedy } = require("./fileUtils");
* @returns {string | undefined} -
*/
function componentJsonReplacer(key, value) {
- if (["handler", "doCleanup", "sbsID", "childLoopRunning"].includes(key)) {
+ if (["handler", "doCleanup", "sbsID", "childLoopRunning", "restartChecked"].includes(key)) {
return undefined;
}
return value;
diff --git a/server/app/core/dispatcher.js b/server/app/core/dispatcher.js
index 8f22554cf..453277acf 100644
--- a/server/app/core/dispatcher.js
+++ b/server/app/core/dispatcher.js
@@ -611,10 +611,11 @@ class Dispatcher extends EventEmitter {
async _loopHandler(getNextIndex, getPrevIndex, isFinished, getTripCount, keepLoopInstance, component) {
getLogger(this.projectRootDir).debug("_loopHandler called", component.name);
- if (component.initialized && component.currentIndex !== null && component.state === "not-started") {
+ if (!component.restartChecked && component.initialized && component.currentIndex !== null && (component.state === "not-started" || component.state === "running")) {
getLogger(this.projectRootDir).debug(`${component.name} is restarting from ${component.currentIndex}`);
component.restarting = true;
}
+ component.restartChecked = true;
if (!component.restarting && component.childLoopRunning) {
//send back itself to searchList for next loop trip
this.pendingComponents.push(component);
@@ -626,7 +627,7 @@ class Dispatcher extends EventEmitter {
loopInitialize(component, getTripCount);
} else if (component.restarting) {
let done = false;
- const currentInstanceDir = path.resolve(this.cwfDir, getInstanceDirectoryName(component, component.prevIndex, component.name));
+ const currentInstanceDir = path.resolve(this.cwfDir, getInstanceDirectoryName(component, component.currentIndex, component.name));
if (await fs.pathExists(currentInstanceDir)) {
const { state } = await readComponentJson(currentInstanceDir);
if (state === "finished") {
@@ -653,6 +654,9 @@ class Dispatcher extends EventEmitter {
component.currentIndex = getNextIndex(component);
}
await this._setComponentState(component, "running");
+ //_setComponentState skips writing if state is unchanged (already "running" from prior iterations).
+ //Force-write to always persist loop progress fields (currentIndex, prevIndex, numFinished, etc.)
+ await writeComponentJson(this.projectRootDir, this._getComponentDir(component.ID), component, true);
component.childLoopRunning = true;
//update env
@@ -778,7 +782,7 @@ class Dispatcher extends EventEmitter {
async _PSHandler(component) {
getLogger(this.projectRootDir).debug("_PSHandler called", component.name);
- if (component.initialized && component.state === "not-started") {
+ if (component.initialized && (component.state === "not-started" || component.state === "running")) {
component.restarting = true;
}
await this._setComponentState(component, "running");
diff --git a/server/app/core/projectController.js b/server/app/core/projectController.js
index e48eaec4d..4437b6810 100644
--- a/server/app/core/projectController.js
+++ b/server/app/core/projectController.js
@@ -100,16 +100,19 @@ async function runProject(projectRootDir) {
}
rootDispatchers.set(projectRootDir, rootDispatcher);
- await updateProjectState(projectRootDir, "running", projectJson);
- getLogger(projectRootDir).info("project start");
- rootWF.state = await rootDispatcher.start();
- getLogger(projectRootDir).info(`project ${rootWF.state}`);
- await updateProjectState(projectRootDir, rootWF.state, projectJson);
- await writeComponentJson(projectRootDir, projectRootDir, rootWF, true);
- rootDispatchers.delete(projectRootDir);
- removeExecuters(projectRootDir);
- removeTransferrers(projectRootDir);
- removeSsh(projectRootDir);
+ try {
+ await updateProjectState(projectRootDir, "running", projectJson);
+ getLogger(projectRootDir).info("project start");
+ rootWF.state = await rootDispatcher.start();
+ getLogger(projectRootDir).info(`project ${rootWF.state}`);
+ await updateProjectState(projectRootDir, rootWF.state, projectJson);
+ await writeComponentJson(projectRootDir, projectRootDir, rootWF, true);
+ } finally {
+ rootDispatchers.delete(projectRootDir);
+ removeExecuters(projectRootDir);
+ removeTransferrers(projectRootDir);
+ removeSsh(projectRootDir);
+ }
return rootWF.state;
}
diff --git a/server/app/core/projectFilesOperator.js b/server/app/core/projectFilesOperator.js
index 7c84b42ac..4cdfe37a4 100644
--- a/server/app/core/projectFilesOperator.js
+++ b/server/app/core/projectFilesOperator.js
@@ -17,7 +17,7 @@ const { projectList, defaultCleanupRemoteRoot, projectJsonFilename, componentJso
const { getDateString, writeJsonWrapper, isValidName, isValidInputFilename, isValidOutputFilename } = require("../lib/utility");
const { replacePathsep, convertPathSep } = require("./pathUtils");
const { readJsonGreedy } = require("./fileUtils");
-const { gitInit, gitAdd, gitCommit, gitRm } = require("./gitOperator2");
+const { gitInit, gitAdd, gitCommit, gitRm, gitStatus } = require("./gitOperator2");
const { hasChild, isLocalComponent } = require("./workflowComponent");
const { getLogger } = require("../logSettings");
const { getSsh } = require("./sshManager.js");
@@ -263,7 +263,7 @@ async function updateComponentPath(projectRootDir, ID, absPath) {
* @param {boolean} force - force update if already set given state
* @returns {object|false} - new Project JSON meta data. false means meta data does not updated
*/
-async function setProjectState(projectRootDir, state, force) {
+async function setProjectState(projectRootDir, state, force, doNotAdd = false) {
const filename = path.resolve(projectRootDir, projectJsonFilename);
const projectJson = await readJsonGreedy(filename);
if (force || projectJson.state !== state) {
@@ -271,7 +271,9 @@ async function setProjectState(projectRootDir, state, force) {
const timestamp = getDateString(true);
projectJson.mtime = timestamp;
await writeJsonWrapper(filename, projectJson);
- await gitAdd(projectRootDir, filename);
+ if (!doNotAdd) {
+ await gitAdd(projectRootDir, filename);
+ }
return projectJson;
}
return false;
@@ -1763,6 +1765,78 @@ async function getComponentTree(projectRootDir, rootDir) {
return root;
}
+/**
+ * Sanitize all staged *.wheel.json files by setting state to "not-started".
+ * Saves original file contents so they can be restored after commit.
+ * @param {string} projectRootDir - project's root path
+ * @returns {Promise>} - list of sanitized files with their original content
+ */
+async function sanitizeStagedJsonFiles(projectRootDir) {
+ const { added, modified } = await gitStatus(projectRootDir);
+ const stagedFiles = [...added, ...modified];
+ const wheelJsonFiles = stagedFiles.filter((f)=>{
+ return f.endsWith(".wheel.json");
+ });
+
+ const originals = [];
+ for (const relPath of wheelJsonFiles) {
+ const filePath = path.resolve(projectRootDir, relPath);
+ const originalContent = await fs.readFile(filePath, "utf8");
+ const json = JSON.parse(originalContent);
+ json.state = "not-started";
+ await writeJsonWrapper(filePath, json);
+ await gitAdd(projectRootDir, filePath);
+ originals.push({ filePath, originalContent });
+ }
+ return originals;
+}
+
+/**
+ * Restore working-tree files to their original content after a sanitized commit.
+ * @param {Array<{filePath: string, originalContent: string}>} originals - list returned by sanitizeStagedJsonFiles
+ * @returns {Promise}
+ */
+async function restoreSanitizedJsonFiles(originals) {
+ for (const { filePath, originalContent } of originals) {
+ await fs.writeFile(filePath, originalContent, "utf8");
+ }
+}
+
+/**
+ * Append a failed task's relative path to prj.wheel.json's failedTasks array.
+ * Does not stage the file (execution-time write).
+ * @param {string} projectRootDir - project's root path
+ * @param {string} taskRelPath - task directory path relative to projectRootDir
+ * @returns {Promise