6 Commits
Author SHA1 Message Date
nsrb 721f15e900 Merge branch 'dev' of https://git.home.0dev.web.id/nsrb/jerapah-flow into dev
Deploy to Raspberry Pi / deploy (push) Successful in 1m44s
2026-08-21 10:34:34 +07:00
nsrb 877ea9e3f8 Merge branch 'main' into dev 2026-08-21 09:31:01 +07:00
nsrb 93b51dcaaa Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Successful in 1m43s
2026-08-20 18:56:34 -04:00
nsrb eb7c318c13 Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Failing after 4s
2026-08-20 18:40:36 -04:00
nsrb 864427b45c feat(workflows): add auto-disable feature for workflows on consecutive failures
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
- Introduced `disableOnConsecutiveFailures` option in workflow triggers to automatically disable workflows after reaching a specified failure threshold.
- Updated related functions to handle the new feature, including persistence of the disabled state and reloading of registries.
- Enhanced UI components to support the new option, allowing users to toggle the auto-disable feature in the workflow configuration.
2026-08-21 05:37:24 +07:00
nsrb 46aa8ca327 add deploy.yml for branch dev pushes
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
2026-08-20 18:09:14 -04:00
7 changed files with 146 additions and 12 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Deploy to Raspberry Pi
on:
push:
branches: [dev]
jobs:
deploy:
runs-on: home # must match a label on your act_runner
steps:
- name: Deploy
run: |
set -euo pipefail
# act_runner uses bash --noprofile --norc; load nvm/pnpm explicitly
export NVM_DIR="/home/nsrb/.nvm"
export PNPM_HOME="/home/nsrb/.local/share/pnpm"
# shellcheck disable=SC1091
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export PATH="$PNPM_HOME/bin:$PATH"
APP_DIR=/home/nsrb/apps/jerapah-flow
cd "$APP_DIR"
git fetch origin dev
git checkout dev
git pull --ff-only origin dev
pnpm install --frozen-lockfile
pnpm build
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
+2 -1
View File
@@ -124,7 +124,8 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune | | `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev | | `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
| `PORT` | `8700` | HTTP API port | | `PORT` | `8700` | HTTP API port |
| `NODE_ENV` | — | Set `production` for secure cookies | | `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
| `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) |
Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick. Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { runId, status: "queued" }` immediately; poll `GET /api/runs/:id` for progress (`queued` → `running` → `success` \| `failed`). Cron remains an in-process producer that enqueues jobs on each tick.
+53 -1
View File
@@ -36,7 +36,9 @@ import {
resolveFailureTriggerConfig, resolveFailureTriggerConfig,
} from "./trigger-failure.js"; } from "./trigger-failure.js";
import { enqueueWorkflowJob } from "./workflow-queue.js"; import { enqueueWorkflowJob } from "./workflow-queue.js";
import { ensureInitialRevision } from "./workflow-history.js"; import { ensureInitialRevision, recordRevision } from "./workflow-history.js";
import { workflowIdFromFile } from "./workflow-normalize.js";
import { publishReload } from "./control-bus.js";
/** /**
* @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry * @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
@@ -652,6 +654,45 @@ export function createRegistry(server, opts = {}) {
} }
} }
/**
* Persist `enabled: false` for a workflow and reload registries across processes.
* @param {string} owner
* @param {string} file
* @param {string} key
*/
async function disableWorkflowForConsecutiveFailures(owner, file, key) {
const content = fsStore.readWorkflowYaml(owner, file);
if (content == null) {
throw new Error(`workflow file missing for ${key}`);
}
const doc = yaml.parseDocument(content);
if (doc.errors?.length) {
throw new Error(doc.errors[0]?.message ?? "invalid yaml");
}
const parsed = doc.toJSON();
if (parsed?.enabled === false) {
log.debug({ workflow: key }, "workflow already disabled");
return;
}
doc.set("enabled", false);
const nextContent = String(doc);
fsStore.writeWorkflowYaml(owner, file, nextContent);
await recordRevision({
workflowId: workflowIdFromFile(file),
owner,
file,
content: nextContent,
reason: "disable-on-consecutive-failures",
});
reregister();
try {
await publishReload({ type: "workflows" });
} catch {
// Redis may be briefly unavailable; local reload already applied.
}
log.warn({ workflow: key }, "disabled workflow after consecutive failures");
}
/** /**
* @param {{ * @param {{
* key: string, * key: string,
@@ -688,6 +729,17 @@ export function createRegistry(server, opts = {}) {
return; return;
} }
if (failureConfig.disableOnConsecutiveFailures) {
const entry = workflows.get(opts.key);
if (entry) {
await disableWorkflowForConsecutiveFailures(entry.owner, entry.file, opts.key);
} else {
log.warn({ workflow: opts.key }, "cannot disable missing workflow entry");
}
}
if (!failureConfig.workflowName) return;
const destKey = resolveWorkflowTriggerKey(opts.owner, failureConfig.workflowName); const destKey = resolveWorkflowTriggerKey(opts.owner, failureConfig.workflowName);
const alertData = buildFailureAlertData({ const alertData = buildFailureAlertData({
sourceKey: opts.key, sourceKey: opts.key,
+1
View File
@@ -69,6 +69,7 @@ function triggerSummary(owner, workflow, nameById) {
schedule: t?.schedule ?? null, schedule: t?.schedule ?? null,
onConsecutiveFailures: t?.onConsecutiveFailures ?? null, onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
onFailureWorkflow: t?.onFailureWorkflow ?? null, onFailureWorkflow: t?.onFailureWorkflow ?? null,
disableOnConsecutiveFailures: t?.disableOnConsecutiveFailures === true,
auth: isHttp ? authLabel(t?.auth, nameById) : null, auth: isHttp ? authLabel(t?.auth, nameById) : null,
}; };
}); });
+19 -5
View File
@@ -47,12 +47,18 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
const threshold = Number(spec.onConsecutiveFailures); const threshold = Number(spec.onConsecutiveFailures);
const workflowName = onFailureWorkflowName(spec); const workflowName = onFailureWorkflowName(spec);
if (!Number.isFinite(threshold) || threshold < 1 || workflowName.length === 0) { const disableOnConsecutiveFailures = isDisableOnConsecutiveFailures(spec);
if (
!Number.isFinite(threshold) ||
threshold < 1 ||
(workflowName.length === 0 && !disableOnConsecutiveFailures)
) {
return null; return null;
} }
return { return {
threshold: Math.floor(threshold), threshold: Math.floor(threshold),
workflowName, workflowName: workflowName.length > 0 ? workflowName : null,
disableOnConsecutiveFailures,
}; };
} }
return null; return null;
@@ -66,6 +72,13 @@ function onFailureWorkflowName(trigger) {
return typeof value === "string" ? value.trim() : ""; return typeof value === "string" ? value.trim() : "";
} }
/**
* @param {Record<string, unknown>} trigger
*/
export function isDisableOnConsecutiveFailures(trigger) {
return trigger?.disableOnConsecutiveFailures === true;
}
/** /**
* @param {unknown} workflow * @param {unknown} workflow
*/ */
@@ -81,12 +94,13 @@ export async function validateWorkflowFailureTriggers(workflow) {
const hasThreshold = const hasThreshold =
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== ""; trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
const hasWorkflow = onFailureWorkflowName(trigger).length > 0; const hasWorkflow = onFailureWorkflowName(trigger).length > 0;
const hasDisable = isDisableOnConsecutiveFailures(trigger);
if (!hasThreshold && !hasWorkflow) continue; if (!hasThreshold && !hasWorkflow && !hasDisable) continue;
if (!hasThreshold || !hasWorkflow) { if (!hasThreshold || (!hasWorkflow && !hasDisable)) {
const err = new Error( const err = new Error(
"onConsecutiveFailures and onFailureWorkflow must both be set on a trigger", "onConsecutiveFailures requires onFailureWorkflow and/or disableOnConsecutiveFailures",
); );
err.statusCode = 400; err.statusCode = 400;
throw err; throw err;
@@ -117,10 +117,15 @@ export function TriggerCard({
function triggerSummary(trigger, owner) { function triggerSummary(trigger, owner) {
const type = trigger?.type; const type = trigger?.type;
const failure = /** @type {string[]} */
trigger.onConsecutiveFailures && trigger.onFailureWorkflow const failureParts = [];
? ` · onFailure@${trigger.onFailureWorkflow}` if (trigger.onConsecutiveFailures && trigger.onFailureWorkflow) {
: ""; failureParts.push(`onFailure@${trigger.onFailureWorkflow}`);
}
if (trigger.disableOnConsecutiveFailures) {
failureParts.push("auto-disable");
}
const failure = failureParts.length ? ` · ${failureParts.join(", ")}` : "";
if (type === "HTTP") { if (type === "HTTP") {
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`; return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`;
} }
@@ -516,6 +521,20 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
) : null} ) : null}
</select> </select>
</Field> </Field>
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={Boolean(trigger.disableOnConsecutiveFailures)}
disabled={disabled}
onChange={(e) =>
onChange({ ...trigger, disableOnConsecutiveFailures: e.target.checked })
}
/>
<span className="label-text">
Disable this workflow when the consecutive failure threshold is reached
</span>
</label>
</div> </div>
); );
} }
+15 -1
View File
@@ -144,6 +144,7 @@ export function newHttpTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -159,6 +160,7 @@ export function newCronTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -174,6 +176,7 @@ export function newWorkflowTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -271,9 +274,16 @@ function normalizeTrigger(raw) {
"unauthorized", "unauthorized",
"onConsecutiveFailures", "onConsecutiveFailures",
"onFailureWorkflow", "onFailureWorkflow",
"disableOnConsecutiveFailures",
]) ])
: type === "cron" : type === "cron"
? new Set(["type", "schedule", "onConsecutiveFailures", "onFailureWorkflow"]) ? new Set([
"type",
"schedule",
"onConsecutiveFailures",
"onFailureWorkflow",
"disableOnConsecutiveFailures",
])
: new Set(["type"]); : new Set(["type"]);
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const extra = {}; const extra = {};
@@ -291,6 +301,7 @@ function normalizeTrigger(raw) {
? "" ? ""
: String(raw.onConsecutiveFailures), : String(raw.onConsecutiveFailures),
onFailureWorkflow: readOnFailureWorkflow(raw), onFailureWorkflow: readOnFailureWorkflow(raw),
disableOnConsecutiveFailures: raw.disableOnConsecutiveFailures === true,
auth: Array.isArray(raw.auth) ? raw.auth : null, auth: Array.isArray(raw.auth) ? raw.auth : null,
response: typeof raw.response === "string" ? raw.response : "", response: typeof raw.response === "string" ? raw.response : "",
unauthorized: raw.unauthorized ?? null, unauthorized: raw.unauthorized ?? null,
@@ -334,6 +345,9 @@ function dumpFailureTriggerFields(t, out) {
if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) { if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) {
out.onFailureWorkflow = t.onFailureWorkflow.trim(); out.onFailureWorkflow = t.onFailureWorkflow.trim();
} }
if (t.disableOnConsecutiveFailures === true) {
out.disableOnConsecutiveFailures = true;
}
} }
function dumpTrigger(t) { function dumpTrigger(t) {