Compare commits
6
Commits
main
...
721f15e900
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
721f15e900 | ||
|
|
877ea9e3f8 | ||
|
|
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_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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user