Compare commits
4
Commits
main
...
93b51dcaaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93b51dcaaa | ||
|
|
eb7c318c13 | ||
|
|
864427b45c | ||
|
|
46aa8ca327 |
@@ -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
|
||||
@@ -124,7 +124,8 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
|
||||
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
resolveFailureTriggerConfig,
|
||||
} from "./trigger-failure.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
|
||||
@@ -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 {{
|
||||
* key: string,
|
||||
@@ -688,6 +729,17 @@ export function createRegistry(server, opts = {}) {
|
||||
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 alertData = buildFailureAlertData({
|
||||
sourceKey: opts.key,
|
||||
|
||||
@@ -69,6 +69,7 @@ function triggerSummary(owner, workflow, nameById) {
|
||||
schedule: t?.schedule ?? null,
|
||||
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
|
||||
onFailureWorkflow: t?.onFailureWorkflow ?? null,
|
||||
disableOnConsecutiveFailures: t?.disableOnConsecutiveFailures === true,
|
||||
auth: isHttp ? authLabel(t?.auth, nameById) : null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -47,12 +47,18 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
|
||||
|
||||
const threshold = Number(spec.onConsecutiveFailures);
|
||||
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 {
|
||||
threshold: Math.floor(threshold),
|
||||
workflowName,
|
||||
workflowName: workflowName.length > 0 ? workflowName : null,
|
||||
disableOnConsecutiveFailures,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -66,6 +72,13 @@ function onFailureWorkflowName(trigger) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} trigger
|
||||
*/
|
||||
export function isDisableOnConsecutiveFailures(trigger) {
|
||||
return trigger?.disableOnConsecutiveFailures === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} workflow
|
||||
*/
|
||||
@@ -81,12 +94,13 @@ export async function validateWorkflowFailureTriggers(workflow) {
|
||||
const hasThreshold =
|
||||
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
|
||||
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(
|
||||
"onConsecutiveFailures and onFailureWorkflow must both be set on a trigger",
|
||||
"onConsecutiveFailures requires onFailureWorkflow and/or disableOnConsecutiveFailures",
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
|
||||
@@ -117,10 +117,15 @@ export function TriggerCard({
|
||||
|
||||
function triggerSummary(trigger, owner) {
|
||||
const type = trigger?.type;
|
||||
const failure =
|
||||
trigger.onConsecutiveFailures && trigger.onFailureWorkflow
|
||||
? ` · onFailure@${trigger.onFailureWorkflow}`
|
||||
: "";
|
||||
/** @type {string[]} */
|
||||
const failureParts = [];
|
||||
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") {
|
||||
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`;
|
||||
}
|
||||
@@ -516,6 +521,20 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
|
||||
) : null}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ export function newHttpTrigger() {
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
onFailureWorkflow: "",
|
||||
disableOnConsecutiveFailures: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,6 +160,7 @@ export function newCronTrigger() {
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
onFailureWorkflow: "",
|
||||
disableOnConsecutiveFailures: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,6 +176,7 @@ export function newWorkflowTrigger() {
|
||||
unauthorized: null,
|
||||
onConsecutiveFailures: "",
|
||||
onFailureWorkflow: "",
|
||||
disableOnConsecutiveFailures: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -271,9 +274,16 @@ function normalizeTrigger(raw) {
|
||||
"unauthorized",
|
||||
"onConsecutiveFailures",
|
||||
"onFailureWorkflow",
|
||||
"disableOnConsecutiveFailures",
|
||||
])
|
||||
: type === "cron"
|
||||
? new Set(["type", "schedule", "onConsecutiveFailures", "onFailureWorkflow"])
|
||||
? new Set([
|
||||
"type",
|
||||
"schedule",
|
||||
"onConsecutiveFailures",
|
||||
"onFailureWorkflow",
|
||||
"disableOnConsecutiveFailures",
|
||||
])
|
||||
: new Set(["type"]);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const extra = {};
|
||||
@@ -291,6 +301,7 @@ function normalizeTrigger(raw) {
|
||||
? ""
|
||||
: String(raw.onConsecutiveFailures),
|
||||
onFailureWorkflow: readOnFailureWorkflow(raw),
|
||||
disableOnConsecutiveFailures: raw.disableOnConsecutiveFailures === true,
|
||||
auth: Array.isArray(raw.auth) ? raw.auth : null,
|
||||
response: typeof raw.response === "string" ? raw.response : "",
|
||||
unauthorized: raw.unauthorized ?? null,
|
||||
@@ -334,6 +345,9 @@ function dumpFailureTriggerFields(t, out) {
|
||||
if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) {
|
||||
out.onFailureWorkflow = t.onFailureWorkflow.trim();
|
||||
}
|
||||
if (t.disableOnConsecutiveFailures === true) {
|
||||
out.disableOnConsecutiveFailures = true;
|
||||
}
|
||||
}
|
||||
|
||||
function dumpTrigger(t) {
|
||||
|
||||
Reference in New Issue
Block a user