nothumanallowed 15.1.70 → 16.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/constants.mjs +1 -1
- package/src/server/index.mjs +12 -0
- package/src/server/routes/connectors.mjs +1142 -10
- package/src/ui-dist/assets/index-BjqGA-KY.css +1 -0
- package/src/ui-dist/assets/index-CQY4us37.js +863 -0
- package/src/ui-dist/index.html +2 -2
- package/src/ui-dist/assets/index-DKPyRmuw.js +0 -862
- package/src/ui-dist/assets/index-IQn8QiFW.css +0 -1
|
@@ -18,15 +18,28 @@ import { loadConfig } from '../../config.mjs';
|
|
|
18
18
|
import { sendJSON, sendError, parseBody } from '../index.mjs';
|
|
19
19
|
import { executeTool } from '../../services/tool-executor.mjs';
|
|
20
20
|
import { callLLM } from '../../services/llm.mjs';
|
|
21
|
+
import { broadcast } from '../ws.mjs';
|
|
21
22
|
|
|
22
23
|
const WORKFLOWS_DIR = path.join(NHA_DIR, 'workflows');
|
|
23
24
|
const VERSIONS_DIR = path.join(NHA_DIR, 'workflows', '_versions');
|
|
25
|
+
const RUNS_DIR = path.join(NHA_DIR, 'workflows', '_runs');
|
|
24
26
|
const CREDS_FILE = path.join(NHA_DIR, 'credentials.json');
|
|
27
|
+
const CONNECTORS_DIR = path.join(NHA_DIR, 'connectors');
|
|
28
|
+
const CONNECTOR_REGISTRY_URL = 'https://nothumanallowed.com/awf/connectors/index.json';
|
|
25
29
|
const MAX_VERSIONS_PER_WF = 50;
|
|
30
|
+
const MAX_RUNS_PER_WF = 30;
|
|
31
|
+
|
|
32
|
+
// Active environment for credentials. Read from NHA_ENV env var or config
|
|
33
|
+
// (responder.env). Defaults to 'prod'. Used to resolve `${cred.NAME}` to
|
|
34
|
+
// the right credential variant when multiple envs are stored.
|
|
35
|
+
function _currentEnv() {
|
|
36
|
+
return (process.env.NHA_ENV || 'prod').toLowerCase();
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
function ensureDir() {
|
|
28
40
|
if (!fs.existsSync(WORKFLOWS_DIR)) fs.mkdirSync(WORKFLOWS_DIR, { recursive: true });
|
|
29
41
|
if (!fs.existsSync(VERSIONS_DIR)) fs.mkdirSync(VERSIONS_DIR, { recursive: true });
|
|
42
|
+
if (!fs.existsSync(RUNS_DIR)) fs.mkdirSync(RUNS_DIR, { recursive: true });
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
function listWorkflows() {
|
|
@@ -420,10 +433,163 @@ async function executeNode(node, nodeDef, ctx, config) {
|
|
|
420
433
|
* triggerPayload: when the workflow was started by a webhook, this is the
|
|
421
434
|
* raw request body, injected into ctx.trigger for downstream nodes.
|
|
422
435
|
*/
|
|
436
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
437
|
+
// Connector marketplace (curated registry + local install)
|
|
438
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
439
|
+
// A "connector" is a DECLARATIVE bundle: nodeDefs + templates + metadata. No
|
|
440
|
+
// arbitrary code execution — actions are limited to mappings onto existing
|
|
441
|
+
// tools registered in tool-executor.mjs. Stored as JSON manifests under
|
|
442
|
+
// ~/.nha/connectors/<id>/manifest.json.
|
|
443
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
444
|
+
|
|
445
|
+
function _ensureConnectorsDir() {
|
|
446
|
+
if (!fs.existsSync(CONNECTORS_DIR)) fs.mkdirSync(CONNECTORS_DIR, { recursive: true });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function listInstalledConnectors() {
|
|
450
|
+
_ensureConnectorsDir();
|
|
451
|
+
const entries = [];
|
|
452
|
+
for (const id of fs.readdirSync(CONNECTORS_DIR)) {
|
|
453
|
+
const manifestPath = path.join(CONNECTORS_DIR, id, 'manifest.json');
|
|
454
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
455
|
+
try {
|
|
456
|
+
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
457
|
+
entries.push({ ...m, _installedAt: fs.statSync(manifestPath).mtime.toISOString() });
|
|
458
|
+
} catch { /* skip malformed */ }
|
|
459
|
+
}
|
|
460
|
+
return entries;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Strict schema validation. Reject anything that looks like an attempt to
|
|
464
|
+
// inject executable strings or paths. This is the defensive-coding moat.
|
|
465
|
+
function validateConnectorManifest(m) {
|
|
466
|
+
const errs = [];
|
|
467
|
+
if (!m || typeof m !== 'object') errs.push('manifest must be an object');
|
|
468
|
+
if (!m.id || !/^[a-z0-9][a-z0-9_-]{1,48}$/.test(m.id)) errs.push('id must be lowercase alphanumeric/-/_ (≤48 chars)');
|
|
469
|
+
if (!m.name || typeof m.name !== 'string' || m.name.length > 80) errs.push('name required (max 80 chars)');
|
|
470
|
+
if (!m.version || !/^\d+\.\d+\.\d+/.test(m.version)) errs.push('version must be semver-like (x.y.z)');
|
|
471
|
+
if (m.nodes && !Array.isArray(m.nodes)) errs.push('nodes must be an array');
|
|
472
|
+
if (m.templates && !Array.isArray(m.templates)) errs.push('templates must be an array');
|
|
473
|
+
// No code fields allowed — reject any property that looks like JS
|
|
474
|
+
for (const k of Object.keys(m)) {
|
|
475
|
+
const v = m[k];
|
|
476
|
+
if (typeof v === 'string' && /\b(eval|Function|require|process\.|fs\.)\b/.test(v)) {
|
|
477
|
+
errs.push(`field "${k}" contains forbidden code-like pattern`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Each node must reference an existing executor path or be a passthrough.
|
|
481
|
+
// We don't validate executor refs here (handled at runtime) but enforce
|
|
482
|
+
// that nodes don't carry script/exec/cmd fields.
|
|
483
|
+
for (const n of m.nodes || []) {
|
|
484
|
+
if (!n.id || !n.type || !n.label) errs.push(`node missing id/type/label: ${JSON.stringify(n).slice(0, 80)}`);
|
|
485
|
+
if (n.exec || n.cmd || n.script || n.handler) errs.push(`node "${n.id}" carries forbidden exec/cmd/script/handler field`);
|
|
486
|
+
}
|
|
487
|
+
return errs;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function installConnector(manifest) {
|
|
491
|
+
const errs = validateConnectorManifest(manifest);
|
|
492
|
+
if (errs.length) throw new Error('Invalid manifest: ' + errs.join('; '));
|
|
493
|
+
_ensureConnectorsDir();
|
|
494
|
+
const dir = path.join(CONNECTORS_DIR, manifest.id);
|
|
495
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
496
|
+
const file = path.join(dir, 'manifest.json');
|
|
497
|
+
fs.writeFileSync(file, JSON.stringify(manifest, null, 2));
|
|
498
|
+
return { id: manifest.id, version: manifest.version, path: file };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function uninstallConnector(id) {
|
|
502
|
+
if (!/^[a-z0-9][a-z0-9_-]{1,48}$/.test(id)) throw new Error('Invalid connector id');
|
|
503
|
+
const dir = path.join(CONNECTORS_DIR, id);
|
|
504
|
+
if (!fs.existsSync(dir)) return false;
|
|
505
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function fetchConnectorRegistry() {
|
|
510
|
+
try {
|
|
511
|
+
const res = await fetch(CONNECTOR_REGISTRY_URL, { signal: AbortSignal.timeout(8000) });
|
|
512
|
+
if (!res.ok) return [];
|
|
513
|
+
const arr = await res.json();
|
|
514
|
+
return Array.isArray(arr) ? arr : [];
|
|
515
|
+
} catch { return []; }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Merge installed connector nodes/templates into the active catalogue. Used
|
|
519
|
+
// by routes that need to surface the full palette to the UI.
|
|
520
|
+
function aggregateConnectorContributions() {
|
|
521
|
+
const nodes = [];
|
|
522
|
+
const templates = [];
|
|
523
|
+
for (const c of listInstalledConnectors()) {
|
|
524
|
+
for (const n of c.nodes || []) {
|
|
525
|
+
// Prefix node ids with connector id to avoid collisions, but keep
|
|
526
|
+
// already-prefixed ids alone.
|
|
527
|
+
const prefixedId = n.id.startsWith(`${c.id}_`) ? n.id : `${c.id}_${n.id}`;
|
|
528
|
+
nodes.push({ ...n, id: prefixedId, _connectorId: c.id });
|
|
529
|
+
}
|
|
530
|
+
for (const t of c.templates || []) templates.push({ ...t, _connectorId: c.id });
|
|
531
|
+
}
|
|
532
|
+
return { nodes, templates };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// In-memory cache of paused-run contexts (workflowId:runId → { ctx, nodeConfig }).
|
|
536
|
+
// Used by the Variable Watcher and Edit-and-Resume features. Bounded to 50
|
|
537
|
+
// entries to avoid leaks if many runs are paused and never resumed.
|
|
538
|
+
const _pausedContexts = new Map();
|
|
539
|
+
function _trimPausedContexts() {
|
|
540
|
+
if (_pausedContexts.size <= 50) return;
|
|
541
|
+
const oldest = [..._pausedContexts.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
|
|
542
|
+
if (oldest) _pausedContexts.delete(oldest[0]);
|
|
543
|
+
}
|
|
544
|
+
|
|
423
545
|
async function runWorkflow(wf, initialInput, config, opts = {}) {
|
|
424
546
|
const depth = opts.depth ?? 0;
|
|
425
547
|
if (depth > 5) return [{ nodeId: '__error', nodeLabel: 'Error', nodeIcon: '❌', output: 'Subworkflow recursion depth limit (5) exceeded.' }];
|
|
426
548
|
|
|
549
|
+
// ── Live streaming: every event ends up in the WS broadcast so the UI can
|
|
550
|
+
// light up nodes in real time as the runner walks through the graph.
|
|
551
|
+
// runId is generated here once and stays constant for the whole execution
|
|
552
|
+
// — the UI uses it to filter events from concurrent runs.
|
|
553
|
+
const runId = opts.runId || `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
554
|
+
const emit = (type, payload) => {
|
|
555
|
+
if (depth > 0) return; // only top-level run emits to avoid flooding on subworkflows
|
|
556
|
+
try { broadcast({ type, runId, wfId: wf.id, ts: Date.now(), ...payload }); } catch {}
|
|
557
|
+
};
|
|
558
|
+
emit('awf:run-start', { input: typeof initialInput === 'string' ? initialInput.slice(0, 500) : '' });
|
|
559
|
+
|
|
560
|
+
// Breakpoints: each entry is either a string (nodeId — always break) or
|
|
561
|
+
// an object { nodeId, condition } where `condition` is a JS expression
|
|
562
|
+
// evaluated against the live context (vars: ctx, input, output). Break
|
|
563
|
+
// ONLY when the expression is truthy. This is the same idea as Chrome
|
|
564
|
+
// DevTools' "conditional breakpoint": you can ignore harmless runs and
|
|
565
|
+
// pause only on the buggy case.
|
|
566
|
+
const normalizeBp = (bp) => typeof bp === 'string' ? { nodeId: bp } : bp;
|
|
567
|
+
const bpList = [
|
|
568
|
+
...(Array.isArray(opts.breakpoints) ? opts.breakpoints : []),
|
|
569
|
+
...(Array.isArray(wf.breakpoints) ? wf.breakpoints : []),
|
|
570
|
+
].map(normalizeBp).filter(b => b && b.nodeId);
|
|
571
|
+
const bpByNode = new Map(bpList.map(b => [b.nodeId, b]));
|
|
572
|
+
// skipBreakpointFor: when resuming from a breakpoint we must not re-pause
|
|
573
|
+
// on the same node that was just stepped through.
|
|
574
|
+
const skipBp = new Set(Array.isArray(opts.skipBreakpointFor) ? opts.skipBreakpointFor : []);
|
|
575
|
+
|
|
576
|
+
// Evaluate a conditional-breakpoint expression in a no-network mini-sandbox.
|
|
577
|
+
// Returns true (= break) if no condition or condition truthy or condition errors.
|
|
578
|
+
// Errors → break (and surface the error to the UI via the step record)
|
|
579
|
+
// so the user knows the expression is malformed.
|
|
580
|
+
const shouldBreak = (bp, evalCtx) => {
|
|
581
|
+
if (!bp) return false;
|
|
582
|
+
if (!bp.condition || !String(bp.condition).trim()) return true;
|
|
583
|
+
try {
|
|
584
|
+
// eslint-disable-next-line no-new-func
|
|
585
|
+
const fn = new Function('ctx', 'input', 'output', `"use strict"; return (${bp.condition});`);
|
|
586
|
+
return !!fn(evalCtx, evalCtx?.input, evalCtx?.output);
|
|
587
|
+
} catch (e) {
|
|
588
|
+
bp._lastError = e?.message || String(e);
|
|
589
|
+
return true; // break with error message visible
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
427
593
|
// Resolve credentials placeholders ${cred.NAME} in every node config BEFORE
|
|
428
594
|
// executing. This way every executeNode call receives plain values — the
|
|
429
595
|
// node implementation doesn't need to know about the credentials system.
|
|
@@ -467,18 +633,84 @@ async function runWorkflow(wf, initialInput, config, opts = {}) {
|
|
|
467
633
|
const queue = startCandidates.map((n) => ({ nodeId: n.id, ctx: { ...baseCtx } }));
|
|
468
634
|
const visited = new Set();
|
|
469
635
|
|
|
636
|
+
// For `logic_merge` nodes: accumulate one output per upstream edge before
|
|
637
|
+
// firing. The merge node executes ONCE with `ctx.__mergeInputs = [...]`.
|
|
638
|
+
const mergeAccumulator = new Map(); // nodeId → string[]
|
|
639
|
+
const incomingCount = new Map();
|
|
640
|
+
for (const e of wf.edges || []) {
|
|
641
|
+
incomingCount.set(e.to, (incomingCount.get(e.to) || 0) + 1);
|
|
642
|
+
}
|
|
643
|
+
|
|
470
644
|
while (queue.length > 0) {
|
|
471
645
|
const { nodeId, ctx } = queue.shift();
|
|
472
|
-
if (visited.has(nodeId)) continue;
|
|
473
|
-
visited.add(nodeId);
|
|
474
646
|
|
|
475
647
|
const node = nodeMap[nodeId];
|
|
476
648
|
if (!node) continue;
|
|
477
649
|
const nodeDef = defMap[node.defId];
|
|
478
650
|
if (!nodeDef) continue;
|
|
479
651
|
|
|
652
|
+
// ── MERGE accumulation: collect upstream outputs BEFORE marking visited.
|
|
653
|
+
// A merge node may be reached multiple times (once per upstream edge);
|
|
654
|
+
// we only execute it when all upstream branches have delivered.
|
|
655
|
+
if (node.defId === 'logic_merge') {
|
|
656
|
+
const acc = mergeAccumulator.get(nodeId) || [];
|
|
657
|
+
acc.push(typeof ctx.output === 'string' ? ctx.output : JSON.stringify(ctx.output ?? ''));
|
|
658
|
+
mergeAccumulator.set(nodeId, acc);
|
|
659
|
+
const need = incomingCount.get(nodeId) || 1;
|
|
660
|
+
if (acc.length < need) continue; // wait for more branches
|
|
661
|
+
ctx.__mergeInputs = acc;
|
|
662
|
+
mergeAccumulator.delete(nodeId);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (visited.has(nodeId)) continue;
|
|
666
|
+
visited.add(nodeId);
|
|
667
|
+
|
|
668
|
+
// ─── BREAKPOINT CHECK ────────────────────────────────────────────────
|
|
669
|
+
// If the user set a breakpoint on this node and (a) we're not explicitly
|
|
670
|
+
// resuming past it AND (b) the breakpoint's condition evaluates truthy,
|
|
671
|
+
// halt here. The remaining queue is preserved in the returned steps
|
|
672
|
+
// array so the UI can show "paused here — resume?".
|
|
673
|
+
const bp = bpByNode.get(nodeId);
|
|
674
|
+
const willBreak = bp && !skipBp.has(nodeId) && shouldBreak(bp, ctx);
|
|
675
|
+
if (willBreak) {
|
|
676
|
+
steps.push({
|
|
677
|
+
nodeId,
|
|
678
|
+
nodeLabel: nodeDef.label,
|
|
679
|
+
nodeIcon: '⏸',
|
|
680
|
+
input: typeof ctx.output === 'string' ? ctx.output.slice(0, 2000) : '',
|
|
681
|
+
output: '',
|
|
682
|
+
error: bp._lastError ? `Conditional breakpoint error: ${bp._lastError}` : null,
|
|
683
|
+
paused: true,
|
|
684
|
+
startedAt: Date.now(),
|
|
685
|
+
endedAt: Date.now(),
|
|
686
|
+
durationMs: 0,
|
|
687
|
+
nodeConfig: node.config || {},
|
|
688
|
+
breakpointCondition: bp.condition || null,
|
|
689
|
+
});
|
|
690
|
+
// Pull every remaining queued node into pendingNodes so the UI can list
|
|
691
|
+
// them in the "what would have run next" pane.
|
|
692
|
+
const pendingNodes = queue.map(q => q.nodeId);
|
|
693
|
+
// Attach a sentinel on the steps array so saveRunSnapshot can detect it.
|
|
694
|
+
steps.__paused = { atNodeId: nodeId, pendingNodes, ctxSnapshot: { output: ctx.output, input: ctx.input } };
|
|
695
|
+
// Cache live context server-side so the UI's variable watcher can fetch
|
|
696
|
+
// it via GET /api/awf/:id/paused-context/:runId.
|
|
697
|
+
_pausedContexts.set(`${wf.id}:${runId}`, {
|
|
698
|
+
atNodeId: nodeId,
|
|
699
|
+
ctxSnapshot: { output: ctx.output, input: ctx.input, loopItem: ctx.loopItem },
|
|
700
|
+
nodeConfig: node.config || {},
|
|
701
|
+
pendingNodes,
|
|
702
|
+
timestamp: Date.now(),
|
|
703
|
+
});
|
|
704
|
+
_trimPausedContexts();
|
|
705
|
+
emit('awf:paused', { atNodeId: nodeId, nodeLabel: nodeDef.label, pendingNodes });
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
|
|
480
709
|
let output = '';
|
|
481
710
|
let error = null;
|
|
711
|
+
const inputSnapshot = typeof ctx.output === 'string' ? ctx.output.slice(0, 4000) : JSON.stringify(ctx.output ?? '').slice(0, 4000);
|
|
712
|
+
const startedAt = Date.now();
|
|
713
|
+
emit('awf:step-start', { nodeId, nodeLabel: nodeDef.label, nodeIcon: nodeDef.icon, input: inputSnapshot.slice(0, 500) });
|
|
482
714
|
|
|
483
715
|
// Error handler: wrap with retry
|
|
484
716
|
const maxRetries = node.defId === 'logic_error' ? parseInt(node.config?.retries || '0') : 0;
|
|
@@ -493,12 +725,45 @@ async function runWorkflow(wf, initialInput, config, opts = {}) {
|
|
|
493
725
|
output = node.config?.fallback || '';
|
|
494
726
|
attempt++;
|
|
495
727
|
if (attempt <= maxRetries) {
|
|
496
|
-
steps.push({
|
|
728
|
+
steps.push({
|
|
729
|
+
nodeId,
|
|
730
|
+
nodeLabel: nodeDef.label,
|
|
731
|
+
nodeIcon: '🔄',
|
|
732
|
+
input: inputSnapshot,
|
|
733
|
+
output: `Retry ${attempt}/${maxRetries}: ${e.message}`,
|
|
734
|
+
error: null,
|
|
735
|
+
startedAt,
|
|
736
|
+
endedAt: Date.now(),
|
|
737
|
+
durationMs: Date.now() - startedAt,
|
|
738
|
+
nodeConfig: node.config || {},
|
|
739
|
+
retry: attempt,
|
|
740
|
+
});
|
|
497
741
|
}
|
|
498
742
|
}
|
|
499
743
|
}
|
|
500
744
|
|
|
501
|
-
|
|
745
|
+
const endedAt = Date.now();
|
|
746
|
+
const stepRecord = {
|
|
747
|
+
nodeId,
|
|
748
|
+
nodeLabel: nodeDef.label,
|
|
749
|
+
nodeIcon: nodeDef.icon,
|
|
750
|
+
input: inputSnapshot,
|
|
751
|
+
output: output?.slice?.(0, 4000) || '',
|
|
752
|
+
error,
|
|
753
|
+
startedAt,
|
|
754
|
+
endedAt,
|
|
755
|
+
durationMs: endedAt - startedAt,
|
|
756
|
+
nodeConfig: node.config || {},
|
|
757
|
+
};
|
|
758
|
+
steps.push(stepRecord);
|
|
759
|
+
emit('awf:step-end', {
|
|
760
|
+
nodeId,
|
|
761
|
+
nodeLabel: nodeDef.label,
|
|
762
|
+
nodeIcon: nodeDef.icon,
|
|
763
|
+
durationMs: endedAt - startedAt,
|
|
764
|
+
error,
|
|
765
|
+
outputPreview: typeof stepRecord.output === 'string' ? stepRecord.output.slice(0, 500) : '',
|
|
766
|
+
});
|
|
502
767
|
|
|
503
768
|
// If error and no error handler downstream, stop this branch
|
|
504
769
|
if (error) {
|
|
@@ -522,11 +787,37 @@ async function runWorkflow(wf, initialInput, config, opts = {}) {
|
|
|
522
787
|
if (!toNode) continue;
|
|
523
788
|
const toDef = defMap[toNode.defId];
|
|
524
789
|
if (!toDef) continue;
|
|
790
|
+
const loopInputSnap = typeof item === 'string' ? item.slice(0, 4000) : JSON.stringify(item).slice(0, 4000);
|
|
791
|
+
const loopStart = Date.now();
|
|
525
792
|
try {
|
|
526
793
|
const loopOut = await executeNode(toNode, toDef, loopCtx, config);
|
|
527
|
-
steps.push({
|
|
794
|
+
steps.push({
|
|
795
|
+
nodeId: toId,
|
|
796
|
+
nodeLabel: `${toDef.label} [${String(item).slice(0, 20)}]`,
|
|
797
|
+
nodeIcon: toDef.icon,
|
|
798
|
+
input: loopInputSnap,
|
|
799
|
+
output: loopOut?.slice?.(0, 4000) || '',
|
|
800
|
+
error: null,
|
|
801
|
+
startedAt: loopStart,
|
|
802
|
+
endedAt: Date.now(),
|
|
803
|
+
durationMs: Date.now() - loopStart,
|
|
804
|
+
nodeConfig: toNode.config || {},
|
|
805
|
+
loopIteration: true,
|
|
806
|
+
});
|
|
528
807
|
} catch (e) {
|
|
529
|
-
steps.push({
|
|
808
|
+
steps.push({
|
|
809
|
+
nodeId: toId,
|
|
810
|
+
nodeLabel: `${toDef.label} [${String(item).slice(0, 20)}]`,
|
|
811
|
+
nodeIcon: toDef.icon,
|
|
812
|
+
input: loopInputSnap,
|
|
813
|
+
output: '',
|
|
814
|
+
error: e.message,
|
|
815
|
+
startedAt: loopStart,
|
|
816
|
+
endedAt: Date.now(),
|
|
817
|
+
durationMs: Date.now() - loopStart,
|
|
818
|
+
nodeConfig: toNode.config || {},
|
|
819
|
+
loopIteration: true,
|
|
820
|
+
});
|
|
530
821
|
}
|
|
531
822
|
}
|
|
532
823
|
}
|
|
@@ -551,9 +842,379 @@ async function runWorkflow(wf, initialInput, config, opts = {}) {
|
|
|
551
842
|
}
|
|
552
843
|
}
|
|
553
844
|
|
|
845
|
+
emit('awf:complete', {
|
|
846
|
+
stepsCount: steps.length,
|
|
847
|
+
errorCount: steps.filter(s => s.error).length,
|
|
848
|
+
totalDurationMs: steps.reduce((a, s) => a + (s.durationMs || 0), 0),
|
|
849
|
+
});
|
|
554
850
|
return steps;
|
|
555
851
|
}
|
|
556
852
|
|
|
853
|
+
|
|
854
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
855
|
+
// Template marketplace — fetch built-in + remote templates from website
|
|
856
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
857
|
+
const TEMPLATE_REMOTE_BASE = 'https://nothumanallowed.com/awf/templates';
|
|
858
|
+
|
|
859
|
+
const BUILTIN_TEMPLATES = [
|
|
860
|
+
{
|
|
861
|
+
id: 'lawyer-hearing-reminders',
|
|
862
|
+
name: '⚖ Lawyer · Hearing reminders',
|
|
863
|
+
category: 'legal',
|
|
864
|
+
description: 'Daily cron at 7am → fetch this week\'s calendar → for each "udienza"/"hearing" event, extract client info via AI and email a reminder. Telegram summary to lawyer.',
|
|
865
|
+
nodes: [
|
|
866
|
+
{ id: 'n1', defId: 'trigger_cron', x: 40, y: 80, config: { schedule: '0 7 * * 1-5' } },
|
|
867
|
+
{ id: 'n2', defId: 'action_webhook',x: 220, y: 80, config: { url: 'http://127.0.0.1:3847/api/tools/calendar_week', method: 'GET' } },
|
|
868
|
+
{ id: 'n3', defId: 'ai_extract', x: 420, y: 80, config: { prompt: 'Extract JSON [{cliente, email, fascicolo, tribunale, ora}] from these events. Only entries matching "udienza"/"hearing": {{output}}' } },
|
|
869
|
+
{ id: 'n4', defId: 'logic_loop', x: 620, y: 80, config: { separator: '\n' } },
|
|
870
|
+
{ id: 'n5', defId: 'action_email', x: 820, y: 40, config: { to: '{{item.email}}', subject: 'Promemoria udienza {{item.fascicolo}}', body: 'Gentile cliente, ricordiamo l\'udienza del {{item.fascicolo}} al {{item.tribunale}} alle {{item.ora}}.' } },
|
|
871
|
+
{ id: 'n6', defId: 'action_notify', x: 820, y: 140, config: { message: '📋 {{count}} udienze questa settimana — reminder inviati', channel: 'telegram' } },
|
|
872
|
+
],
|
|
873
|
+
edges: [
|
|
874
|
+
{ from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' }, { from: 'n3', to: 'n4' },
|
|
875
|
+
{ from: 'n4', to: 'n5' }, { from: 'n4', to: 'n6' },
|
|
876
|
+
],
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
id: 'accountant-invoice-extraction',
|
|
880
|
+
name: '📊 Accountant · Invoice extraction from email',
|
|
881
|
+
category: 'finance',
|
|
882
|
+
description: 'Trigger on emails with subject "fattura"/"invoice" or PDF attachment → parse with AI → save PDF to Drive → row to Notion CRM → Telegram alert if total > €1000.',
|
|
883
|
+
nodes: [
|
|
884
|
+
{ id: 'n1', defId: 'trigger_email', x: 40, y: 80, config: { filter: 'subject:fattura OR has:attachment' } },
|
|
885
|
+
{ id: 'n2', defId: 'ai_extract', x: 240, y: 80, config: { prompt: 'Extract {fornitore, piva, numero, data, imponibile, iva, totale} from this email: {{output}}' } },
|
|
886
|
+
{ id: 'n3', defId: 'logic_if', x: 440, y: 80, config: { condition: '{{output.totale}} > 1000' } },
|
|
887
|
+
{ id: 'n4', defId: 'action_notify', x: 640, y: 30, config: { message: '⚠ Fattura > €1k da {{output.fornitore}} ({{output.totale}}€)', channel: 'telegram' } },
|
|
888
|
+
{ id: 'n5', defId: 'action_drive', x: 640, y: 130, config: { name: 'Fattura_{{output.numero}}_{{output.data}}.pdf', content: '{{output}}' } },
|
|
889
|
+
{ id: 'n6', defId: 'action_notion', x: 840, y: 130, config: { title: 'Fattura {{output.numero}} — {{output.fornitore}}', content: 'Imponibile: {{output.imponibile}}€ · IVA: {{output.iva}}€ · Totale: {{output.totale}}€ · Data: {{output.data}}' } },
|
|
890
|
+
],
|
|
891
|
+
edges: [
|
|
892
|
+
{ from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' },
|
|
893
|
+
{ from: 'n3', to: 'n4', label: 'true' }, { from: 'n3', to: 'n5', label: 'false' },
|
|
894
|
+
{ from: 'n5', to: 'n6' },
|
|
895
|
+
],
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
id: 'freelance-daily-brief',
|
|
899
|
+
name: '💼 Freelance · Daily morning brief',
|
|
900
|
+
category: 'productivity',
|
|
901
|
+
description: 'Cron 8am Mon-Fri → fetch today\'s calendar + unread email + tasks + market news → summarize in 5 bullets → email + Telegram.',
|
|
902
|
+
nodes: [
|
|
903
|
+
{ id: 'n1', defId: 'trigger_cron', x: 40, y: 100, config: { schedule: '0 8 * * 1-5' } },
|
|
904
|
+
{ id: 'n2', defId: 'action_webhook', x: 220, y: 30, config: { url: 'http://127.0.0.1:3847/api/tools/calendar_today', method: 'GET' } },
|
|
905
|
+
{ id: 'n3', defId: 'action_webhook', x: 220, y: 100, config: { url: 'http://127.0.0.1:3847/api/tools/task_list', method: 'GET' } },
|
|
906
|
+
{ id: 'n4', defId: 'action_webhook', x: 220, y: 170, config: { url: 'http://127.0.0.1:3847/api/tools/gmail_list', method: 'POST', body: '{"query":"is:unread is:important"}' } },
|
|
907
|
+
{ id: 'n5', defId: 'logic_merge', x: 420, y: 100, config: { mode: 'concat' } },
|
|
908
|
+
{ id: 'n6', defId: 'ai_summarize', x: 620, y: 100, config: { prompt: 'Brief in 5 bullet (italiano): appuntamenti oggi, task urgenti, email da rispondere, news settore, priorità #1. Input: {{output}}' } },
|
|
909
|
+
{ id: 'n7', defId: 'action_email', x: 820, y: 60, config: { to: 'me', subject: '☀ Brief del {{date}}', body: '{{output}}' } },
|
|
910
|
+
{ id: 'n8', defId: 'action_notify', x: 820, y: 140, config: { message: '{{output}}', channel: 'telegram' } },
|
|
911
|
+
],
|
|
912
|
+
edges: [
|
|
913
|
+
{ from: 'n1', to: 'n2' }, { from: 'n1', to: 'n3' }, { from: 'n1', to: 'n4' },
|
|
914
|
+
{ from: 'n2', to: 'n5' }, { from: 'n3', to: 'n5' }, { from: 'n4', to: 'n5' },
|
|
915
|
+
{ from: 'n5', to: 'n6' }, { from: 'n6', to: 'n7' }, { from: 'n6', to: 'n8' },
|
|
916
|
+
],
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
id: 'company-support-triage',
|
|
920
|
+
name: '🏢 Company · Customer support triage',
|
|
921
|
+
category: 'business',
|
|
922
|
+
description: 'Email to info@ → AI classify (urgent/sales/invoice/complaint/spam) → switch route → Slack alert / Notion CRM / forward to accountant / quality team / trash.',
|
|
923
|
+
nodes: [
|
|
924
|
+
{ id: 'n1', defId: 'trigger_email', x: 40, y: 200, config: { filter: 'to:info@' } },
|
|
925
|
+
{ id: 'n2', defId: 'ai_classify', x: 220, y: 200, config: { categories: 'urgent, sales, invoice, complaint, spam', prompt: 'Classify customer email: {{output}}' } },
|
|
926
|
+
{ id: 'n3', defId: 'logic_switch', x: 420, y: 200, config: { expression: '{{output}}', cases: 'urgent, sales, invoice, complaint, spam' } },
|
|
927
|
+
{ id: 'n4', defId: 'action_slack', x: 640, y: 40, config: { channel: '#support-urgent', text: '🚨 URGENT email: {{output}}' } },
|
|
928
|
+
{ id: 'n5', defId: 'action_notion', x: 640, y: 130, config: { title: 'Lead from email', content: '{{output}}' } },
|
|
929
|
+
{ id: 'n6', defId: 'action_email', x: 640, y: 220, config: { to: 'commercialista@studio.it', subject: 'Fattura ricevuta', body: 'Forward: {{output}}' } },
|
|
930
|
+
{ id: 'n7', defId: 'action_slack', x: 640, y: 310, config: { channel: '#quality', text: 'Reclamo da rivedere: {{output}}' } },
|
|
931
|
+
{ id: 'n8', defId: 'action_notify', x: 640, y: 400, config: { message: 'Spam filtered', channel: 'system' } },
|
|
932
|
+
],
|
|
933
|
+
edges: [
|
|
934
|
+
{ from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' },
|
|
935
|
+
{ from: 'n3', to: 'n4', label: 'urgent' }, { from: 'n3', to: 'n5', label: 'sales' },
|
|
936
|
+
{ from: 'n3', to: 'n6', label: 'invoice' }, { from: 'n3', to: 'n7', label: 'complaint' },
|
|
937
|
+
{ from: 'n3', to: 'n8', label: 'spam' },
|
|
938
|
+
],
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
id: 'company-lead-generation',
|
|
942
|
+
name: '🚀 Company · Lead generation + nurturing',
|
|
943
|
+
category: 'business',
|
|
944
|
+
description: 'Mon/Wed/Fri 9am → scrape LinkedIn/Google → AI qualify (hot/warm/cold) → CRM → personalized follow-up email → 3-day delay → second touch.',
|
|
945
|
+
nodes: [
|
|
946
|
+
{ id: 'n1', defId: 'trigger_cron', x: 40, y: 100, config: { schedule: '0 9 * * 1,3,5' } },
|
|
947
|
+
{ id: 'n2', defId: 'action_browser', x: 220, y: 100, config: { url: 'https://www.google.com/search?q=CTO+startup+SaaS+Italy+site:linkedin.com' } },
|
|
948
|
+
{ id: 'n3', defId: 'ai_extract', x: 420, y: 100, config: { prompt: 'Extract JSON [{nome, ruolo, azienda, email?, settore}] of decision-makers from: {{output}}' } },
|
|
949
|
+
{ id: 'n4', defId: 'logic_loop', x: 620, y: 100, config: { separator: '\n' } },
|
|
950
|
+
{ id: 'n5', defId: 'ai_agent', x: 820, y: 100, config: { agent: 'saber', prompt: 'Qualify this lead for B2B SaaS sales. Output ONLY: hot, warm, cold, or junk. Lead: {{item}}' } },
|
|
951
|
+
{ id: 'n6', defId: 'logic_switch', x: 1020,y: 100, config: { expression: '{{output}}', cases: 'hot, warm' } },
|
|
952
|
+
{ id: 'n7', defId: 'action_notion', x: 1220,y: 40, config: { title: '🔥 HOT — {{item.nome}}', content: '{{item.azienda}} · {{item.ruolo}}' } },
|
|
953
|
+
{ id: 'n8', defId: 'action_email', x: 1420,y: 40, config: { to: '{{item.email}}', subject: 'Quick chat about {{item.azienda}}?', body: 'Hi {{item.nome}}, noticed your role at {{item.azienda}}. Worth a 15-min chat?' } },
|
|
954
|
+
{ id: 'n9', defId: 'action_notion', x: 1220,y: 160, config: { title: '🟡 Nurturing — {{item.nome}}', content: '{{item.azienda}}' } },
|
|
955
|
+
],
|
|
956
|
+
edges: [
|
|
957
|
+
{ from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' }, { from: 'n3', to: 'n4' },
|
|
958
|
+
{ from: 'n4', to: 'n5' }, { from: 'n5', to: 'n6' },
|
|
959
|
+
{ from: 'n6', to: 'n7', label: 'hot' }, { from: 'n7', to: 'n8' },
|
|
960
|
+
{ from: 'n6', to: 'n9', label: 'warm' },
|
|
961
|
+
],
|
|
962
|
+
},
|
|
963
|
+
];
|
|
964
|
+
|
|
965
|
+
async function fetchRemoteTemplates() {
|
|
966
|
+
// Best-effort fetch of remote template index. Falls back to builtin only.
|
|
967
|
+
try {
|
|
968
|
+
const res = await fetch(`${TEMPLATE_REMOTE_BASE}/index.json`, { signal: AbortSignal.timeout(8000) });
|
|
969
|
+
if (!res.ok) return [];
|
|
970
|
+
const arr = await res.json();
|
|
971
|
+
return Array.isArray(arr) ? arr : [];
|
|
972
|
+
} catch { return []; }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
976
|
+
// Run history (debugger replay)
|
|
977
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
978
|
+
function saveRunSnapshot(workflowId, steps, opts = {}) {
|
|
979
|
+
ensureDir();
|
|
980
|
+
const runDir = path.join(RUNS_DIR, workflowId);
|
|
981
|
+
if (!fs.existsSync(runDir)) fs.mkdirSync(runDir, { recursive: true });
|
|
982
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
983
|
+
|
|
984
|
+
// If the run is paused, also register the live context under the snapshot's
|
|
985
|
+
// ts so the UI can fetch it via `/api/awf/:id/paused-context/:ts` after the
|
|
986
|
+
// HTTP response lands (the client knows the ts, not the internal runId).
|
|
987
|
+
if (steps?.__paused) {
|
|
988
|
+
const existing = [..._pausedContexts.values()].find(
|
|
989
|
+
v => v.atNodeId === steps.__paused.atNodeId && Date.now() - v.timestamp < 5000
|
|
990
|
+
);
|
|
991
|
+
if (existing) _pausedContexts.set(`${workflowId}:${ts}`, existing);
|
|
992
|
+
}
|
|
993
|
+
const file = path.join(runDir, `${ts}.json`);
|
|
994
|
+
const paused = steps?.__paused || null;
|
|
995
|
+
// Drop the non-serializable __paused sentinel from the array shape.
|
|
996
|
+
const stepsClean = Array.isArray(steps) ? Array.from(steps) : [];
|
|
997
|
+
const totalDuration = stepsClean.reduce((s, x) => s + (x.durationMs || 0), 0);
|
|
998
|
+
const errorCount = stepsClean.filter(x => x.error).length;
|
|
999
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
1000
|
+
ts,
|
|
1001
|
+
workflowId,
|
|
1002
|
+
input: opts.input || '',
|
|
1003
|
+
triggerPayload: opts.triggerPayload ?? null,
|
|
1004
|
+
triggerType: opts.triggerType || 'manual',
|
|
1005
|
+
wfSnapshot: opts.wfSnapshot || null, // {name, nodes, edges} at run time
|
|
1006
|
+
steps: stepsClean,
|
|
1007
|
+
paused,
|
|
1008
|
+
totalDurationMs: totalDuration,
|
|
1009
|
+
errorCount,
|
|
1010
|
+
env: _currentEnv(),
|
|
1011
|
+
}, null, 2));
|
|
1012
|
+
// Prune old runs
|
|
1013
|
+
const all = fs.readdirSync(runDir).filter(f => f.endsWith('.json')).sort();
|
|
1014
|
+
if (all.length > MAX_RUNS_PER_WF) {
|
|
1015
|
+
all.slice(0, all.length - MAX_RUNS_PER_WF).forEach(f => fs.unlinkSync(path.join(runDir, f)));
|
|
1016
|
+
}
|
|
1017
|
+
return ts;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function listRunSnapshots(workflowId) {
|
|
1021
|
+
const runDir = path.join(RUNS_DIR, workflowId);
|
|
1022
|
+
if (!fs.existsSync(runDir)) return [];
|
|
1023
|
+
return fs.readdirSync(runDir).filter(f => f.endsWith('.json')).map(f => f.replace('.json', '')).sort().reverse();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function loadRunSnapshot(workflowId, ts) {
|
|
1027
|
+
const file = path.join(RUNS_DIR, workflowId, `${ts}.json`);
|
|
1028
|
+
if (!fs.existsSync(file)) return null;
|
|
1029
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return null; }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1033
|
+
// Advanced trigger watchers (file watch, IMAP folder watch, RSS feed)
|
|
1034
|
+
// Discord triggers piggyback on the existing DiscordResponder.
|
|
1035
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1036
|
+
const _activeWatchers = new Map(); // workflowId+nodeId → { close: fn }
|
|
1037
|
+
|
|
1038
|
+
function _startFileWatch(workflow, node) {
|
|
1039
|
+
const key = `${workflow.id}:${node.id}`;
|
|
1040
|
+
if (_activeWatchers.has(key)) return; // already running
|
|
1041
|
+
const watchPath = node.config?.path;
|
|
1042
|
+
if (!watchPath || !fs.existsSync(watchPath)) return;
|
|
1043
|
+
try {
|
|
1044
|
+
const w = fs.watch(watchPath, { recursive: !!node.config?.recursive }, async (eventType, filename) => {
|
|
1045
|
+
// Debounce: skip if last event < 500ms ago
|
|
1046
|
+
const now = Date.now();
|
|
1047
|
+
if (w._lastEvent && now - w._lastEvent < 500) return;
|
|
1048
|
+
w._lastEvent = now;
|
|
1049
|
+
try {
|
|
1050
|
+
const fullPath = path.join(watchPath, filename || '');
|
|
1051
|
+
const config = loadConfig();
|
|
1052
|
+
const steps = await runWorkflow(workflow, JSON.stringify({ event: eventType, path: fullPath }), config, { triggerPayload: { event: eventType, path: fullPath } });
|
|
1053
|
+
saveRunSnapshot(workflow.id, steps, {
|
|
1054
|
+
input: `file_watch:${eventType}:${filename}`,
|
|
1055
|
+
triggerType: 'file_watch',
|
|
1056
|
+
triggerPayload: { event: eventType, path: fullPath },
|
|
1057
|
+
wfSnapshot: { name: workflow.name, nodes: workflow.nodes, edges: workflow.edges },
|
|
1058
|
+
});
|
|
1059
|
+
} catch {}
|
|
1060
|
+
});
|
|
1061
|
+
_activeWatchers.set(key, { close: () => { try { w.close(); } catch {} } });
|
|
1062
|
+
} catch {}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function _fetchRssFeed(url) {
|
|
1066
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15000), headers: { 'User-Agent': 'NHA-AWF/1.0' } });
|
|
1067
|
+
if (!res.ok) return [];
|
|
1068
|
+
const xml = await res.text();
|
|
1069
|
+
// Minimal XML parser: extract <item>…</item> blocks.
|
|
1070
|
+
const items = [];
|
|
1071
|
+
const itemRe = /<(?:item|entry)\b[^>]*>([\s\S]*?)<\/(?:item|entry)>/gi;
|
|
1072
|
+
let m;
|
|
1073
|
+
while ((m = itemRe.exec(xml))) {
|
|
1074
|
+
const block = m[1];
|
|
1075
|
+
const tag = (name) => {
|
|
1076
|
+
const r = new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)<\\/${name}>`, 'i');
|
|
1077
|
+
const mm = block.match(r);
|
|
1078
|
+
return mm ? mm[1].replace(/<!\[CDATA\[|\]\]>/g, '').trim() : '';
|
|
1079
|
+
};
|
|
1080
|
+
items.push({
|
|
1081
|
+
title: tag('title'),
|
|
1082
|
+
link: tag('link') || (block.match(/<link\b[^>]*href="([^"]+)"/i)?.[1] || ''),
|
|
1083
|
+
guid: tag('guid') || tag('id') || tag('link'),
|
|
1084
|
+
pubDate: tag('pubDate') || tag('updated') || tag('published'),
|
|
1085
|
+
description: (tag('description') || tag('summary') || tag('content')).replace(/<[^>]+>/g, '').slice(0, 500),
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
return items;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const _rssSeenItems = new Map(); // url → Set<guid>
|
|
1092
|
+
function _startRssWatch(workflow, node) {
|
|
1093
|
+
const key = `${workflow.id}:${node.id}`;
|
|
1094
|
+
if (_activeWatchers.has(key)) return;
|
|
1095
|
+
const url = node.config?.url;
|
|
1096
|
+
if (!url) return;
|
|
1097
|
+
const intervalMin = parseInt(node.config?.intervalMin || '15', 10);
|
|
1098
|
+
if (!_rssSeenItems.has(url)) _rssSeenItems.set(url, new Set());
|
|
1099
|
+
const seen = _rssSeenItems.get(url);
|
|
1100
|
+
|
|
1101
|
+
const tick = async () => {
|
|
1102
|
+
try {
|
|
1103
|
+
const items = await _fetchRssFeed(url);
|
|
1104
|
+
const newItems = items.filter(it => it.guid && !seen.has(it.guid));
|
|
1105
|
+
newItems.forEach(it => seen.add(it.guid));
|
|
1106
|
+
// Cap seen set to 500 most recent
|
|
1107
|
+
if (seen.size > 500) { const arr = [...seen]; seen.clear(); arr.slice(-500).forEach(g => seen.add(g)); }
|
|
1108
|
+
for (const item of newItems) {
|
|
1109
|
+
const config = loadConfig();
|
|
1110
|
+
const steps = await runWorkflow(workflow, `${item.title}\n${item.link}\n\n${item.description}`, config, { triggerPayload: item });
|
|
1111
|
+
saveRunSnapshot(workflow.id, steps, {
|
|
1112
|
+
input: `rss:${item.title.slice(0, 60)}`,
|
|
1113
|
+
triggerType: 'rss',
|
|
1114
|
+
triggerPayload: item,
|
|
1115
|
+
wfSnapshot: { name: workflow.name, nodes: workflow.nodes, edges: workflow.edges },
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
} catch {}
|
|
1119
|
+
};
|
|
1120
|
+
// Fire on schedule but DON'T fire on startup (only new items after start)
|
|
1121
|
+
const handle = setInterval(tick, intervalMin * 60_000);
|
|
1122
|
+
// Seed the seen set so historical items don't trigger
|
|
1123
|
+
_fetchRssFeed(url).then(items => items.forEach(it => it.guid && seen.add(it.guid))).catch(() => {});
|
|
1124
|
+
_activeWatchers.set(key, { close: () => { clearInterval(handle); } });
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Start advanced watchers for every workflow that has the matching trigger.
|
|
1128
|
+
export function startAdvancedTriggers() {
|
|
1129
|
+
const wfs = listWorkflows();
|
|
1130
|
+
for (const wf of wfs) {
|
|
1131
|
+
if (wf.enabled === false) continue;
|
|
1132
|
+
for (const node of wf.nodes || []) {
|
|
1133
|
+
if (node.defId === 'trigger_file_watch') _startFileWatch(wf, node);
|
|
1134
|
+
if (node.defId === 'trigger_rss') _startRssWatch(wf, node);
|
|
1135
|
+
// trigger_imap_folder + trigger_discord_message are hooked from the
|
|
1136
|
+
// respective responders/imap services — they call dispatchTriggerHook below.
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Called externally by IMAP IDLE handler / DiscordResponder when a matching
|
|
1142
|
+
// event fires. payload is whatever the source wants to surface.
|
|
1143
|
+
export async function dispatchTriggerHook(triggerType, matcher, payload) {
|
|
1144
|
+
const wfs = listWorkflows();
|
|
1145
|
+
for (const wf of wfs) {
|
|
1146
|
+
if (wf.enabled === false) continue;
|
|
1147
|
+
for (const node of wf.nodes || []) {
|
|
1148
|
+
if (node.defId !== triggerType) continue;
|
|
1149
|
+
// matcher: a function (nodeConfig) => bool. If returns true, fire.
|
|
1150
|
+
if (matcher && !matcher(node.config || {})) continue;
|
|
1151
|
+
try {
|
|
1152
|
+
const config = loadConfig();
|
|
1153
|
+
const steps = await runWorkflow(wf, JSON.stringify(payload || {}), config, { triggerPayload: payload });
|
|
1154
|
+
saveRunSnapshot(wf.id, steps, {
|
|
1155
|
+
input: `${triggerType}:hook`,
|
|
1156
|
+
triggerType,
|
|
1157
|
+
triggerPayload: payload,
|
|
1158
|
+
wfSnapshot: { name: wf.name, nodes: wf.nodes, edges: wf.edges },
|
|
1159
|
+
});
|
|
1160
|
+
} catch {}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1166
|
+
// Workspace sync (Drive) + automatic backup
|
|
1167
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1168
|
+
async function backupWorkflowsToDrive(config, opts = {}) {
|
|
1169
|
+
const { uploadFile } = await import('../../services/google-drive.mjs').catch(() => ({}));
|
|
1170
|
+
if (!uploadFile) return { ok: false, error: 'Google Drive service unavailable' };
|
|
1171
|
+
const wfs = listWorkflows();
|
|
1172
|
+
const creds = (() => { try { return JSON.parse(fs.readFileSync(CREDS_FILE, 'utf-8')); } catch { return {}; } })();
|
|
1173
|
+
// Credentials are STILL encrypted in the backup — we ship the raw file as
|
|
1174
|
+
// exported (already AES-256-GCM with machine-bound salt). Restoring on a
|
|
1175
|
+
// different machine requires the salt to be backed up separately, by design.
|
|
1176
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1177
|
+
const payload = {
|
|
1178
|
+
backedUpAt: new Date().toISOString(),
|
|
1179
|
+
nhaVersion: (await import('../../constants.mjs')).VERSION,
|
|
1180
|
+
workflows: wfs,
|
|
1181
|
+
credentialsEncrypted: creds, // already encrypted
|
|
1182
|
+
};
|
|
1183
|
+
// syncTag uses a distinct filename prefix so /sync/pull can locate the
|
|
1184
|
+
// most recent push deterministically; backups without syncTag use the
|
|
1185
|
+
// legacy prefix for traceability.
|
|
1186
|
+
const prefix = opts.syncTag ? 'nha-awf-sync' : 'nha-awf-backup';
|
|
1187
|
+
const fileName = `${prefix}-${ts}.json`;
|
|
1188
|
+
try {
|
|
1189
|
+
const result = await uploadFile(config, {
|
|
1190
|
+
name: fileName,
|
|
1191
|
+
content: JSON.stringify(payload, null, 2),
|
|
1192
|
+
mimeType: 'application/json',
|
|
1193
|
+
folder: opts.folder || 'NHA Backups',
|
|
1194
|
+
});
|
|
1195
|
+
return { ok: true, fileId: result?.id, fileName };
|
|
1196
|
+
} catch (e) {
|
|
1197
|
+
return { ok: false, error: e.message };
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Schedule automatic backup every N hours (default 24h). Idempotent.
|
|
1202
|
+
let _backupTimer = null;
|
|
1203
|
+
export function startAutoBackup(intervalHours = 24) {
|
|
1204
|
+
if (_backupTimer) return;
|
|
1205
|
+
const config = loadConfig();
|
|
1206
|
+
const hasDriveAuth = !!(config.google?.accessToken || config.google?.refreshToken);
|
|
1207
|
+
if (!hasDriveAuth) return;
|
|
1208
|
+
const tick = async () => {
|
|
1209
|
+
try { await backupWorkflowsToDrive(loadConfig()); } catch {}
|
|
1210
|
+
};
|
|
1211
|
+
_backupTimer = setInterval(tick, intervalHours * 60 * 60 * 1000);
|
|
1212
|
+
// Also fire one immediately, but delayed so we don't block startup.
|
|
1213
|
+
setTimeout(tick, 5 * 60 * 1000);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
1217
|
+
|
|
557
1218
|
export function register(router) {
|
|
558
1219
|
// GET /api/workflows — list all workflows
|
|
559
1220
|
router.get('/api/workflows', async (req, res) => {
|
|
@@ -612,13 +1273,25 @@ export function register(router) {
|
|
|
612
1273
|
|
|
613
1274
|
const wf = JSON.parse(fs.readFileSync(wfPath, 'utf-8'));
|
|
614
1275
|
const config = loadConfig();
|
|
615
|
-
const steps = await runWorkflow(wf, body.input || '', config
|
|
1276
|
+
const steps = await runWorkflow(wf, body.input || '', config, {
|
|
1277
|
+
breakpoints: body.breakpoints,
|
|
1278
|
+
skipBreakpointFor: body.skipBreakpointFor,
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
// Always persist a run snapshot — this is the only reason the debugger
|
|
1282
|
+
// has history. Include a lightweight wfSnapshot so replay can rebuild the
|
|
1283
|
+
// exact graph even if the user edits the workflow afterwards.
|
|
1284
|
+
const wfSnapshot = { name: wf.name, nodes: wf.nodes, edges: wf.edges };
|
|
1285
|
+
const runTs = saveRunSnapshot(wf.id, steps, {
|
|
1286
|
+
input: body.input || '',
|
|
1287
|
+
triggerType: 'manual',
|
|
1288
|
+
wfSnapshot,
|
|
1289
|
+
});
|
|
616
1290
|
|
|
617
|
-
|
|
618
|
-
wf.lastRun = { at: new Date().toISOString(), steps };
|
|
1291
|
+
wf.lastRun = { at: new Date().toISOString(), ts: runTs, steps };
|
|
619
1292
|
saveWorkflow(wf);
|
|
620
1293
|
|
|
621
|
-
sendJSON(res, 200, { ok: true, steps });
|
|
1294
|
+
sendJSON(res, 200, { ok: true, ts: runTs, steps, paused: steps?.__paused || null });
|
|
622
1295
|
} catch (e) {
|
|
623
1296
|
sendError(res, 500, e.message);
|
|
624
1297
|
}
|
|
@@ -726,4 +1399,463 @@ export function register(router) {
|
|
|
726
1399
|
router.get( '/api/webhooks/:slug', _handleWebhook);
|
|
727
1400
|
router.put( '/api/webhooks/:slug', _handleWebhook);
|
|
728
1401
|
router.delete('/api/webhooks/:slug', _handleWebhook);
|
|
1402
|
+
|
|
1403
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1404
|
+
// CONNECTOR MARKETPLACE — install/uninstall declarative bundles
|
|
1405
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1406
|
+
router.get('/api/awf/connectors/installed', async (_req, res) => {
|
|
1407
|
+
try { sendJSON(res, 200, { connectors: listInstalledConnectors() }); }
|
|
1408
|
+
catch (e) { sendError(res, 500, e.message); }
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
router.get('/api/awf/connectors/registry', async (_req, res) => {
|
|
1412
|
+
try {
|
|
1413
|
+
const reg = await fetchConnectorRegistry();
|
|
1414
|
+
const installed = new Set(listInstalledConnectors().map(c => c.id));
|
|
1415
|
+
// Mark which are already installed so the UI can show "✓ installed"
|
|
1416
|
+
const enriched = reg.map(c => ({ ...c, installed: installed.has(c.id) }));
|
|
1417
|
+
sendJSON(res, 200, { connectors: enriched });
|
|
1418
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
// POST /api/awf/connectors/install — body: { manifest? OR url? }
|
|
1422
|
+
router.post('/api/awf/connectors/install', async (req, res) => {
|
|
1423
|
+
try {
|
|
1424
|
+
const body = await parseBody(req);
|
|
1425
|
+
let manifest = body.manifest;
|
|
1426
|
+
if (!manifest && body.url) {
|
|
1427
|
+
// Allowlist of trusted registry hosts. Anything else requires a body.manifest.
|
|
1428
|
+
const ALLOWED = ['nothumanallowed.com', 'raw.githubusercontent.com'];
|
|
1429
|
+
const u = new URL(body.url);
|
|
1430
|
+
if (!ALLOWED.some(h => u.hostname === h || u.hostname.endsWith(`.${h}`))) {
|
|
1431
|
+
return sendError(res, 400, `URL host not in allowlist: ${u.hostname}`);
|
|
1432
|
+
}
|
|
1433
|
+
const r = await fetch(body.url, { signal: AbortSignal.timeout(10000) });
|
|
1434
|
+
if (!r.ok) return sendError(res, 502, `Manifest fetch failed: ${r.status}`);
|
|
1435
|
+
manifest = await r.json();
|
|
1436
|
+
}
|
|
1437
|
+
if (!manifest) return sendError(res, 400, 'Provide either { manifest } or { url }');
|
|
1438
|
+
const result = installConnector(manifest);
|
|
1439
|
+
sendJSON(res, 201, { ok: true, ...result });
|
|
1440
|
+
} catch (e) { sendError(res, 400, e.message); }
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
router.delete('/api/awf/connectors/:id', async (req, res) => {
|
|
1444
|
+
try {
|
|
1445
|
+
const ok = uninstallConnector(req.params.id);
|
|
1446
|
+
sendJSON(res, 200, { ok });
|
|
1447
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
// GET /api/awf/node-defs — palette = built-in (provided by client) + connector
|
|
1451
|
+
// contributions (server-side). The UI merges these into NODE_DEFS.
|
|
1452
|
+
router.get('/api/awf/node-defs', async (_req, res) => {
|
|
1453
|
+
try {
|
|
1454
|
+
const contrib = aggregateConnectorContributions();
|
|
1455
|
+
sendJSON(res, 200, { nodes: contrib.nodes, templates: contrib.templates });
|
|
1456
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1460
|
+
// TEMPLATES MARKETPLACE — list + import (1-click)
|
|
1461
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1462
|
+
router.get('/api/awf/templates', async (_req, res) => {
|
|
1463
|
+
try {
|
|
1464
|
+
const remote = await fetchRemoteTemplates();
|
|
1465
|
+
const fromConnectors = aggregateConnectorContributions().templates;
|
|
1466
|
+
const all = [...BUILTIN_TEMPLATES, ...fromConnectors, ...remote];
|
|
1467
|
+
sendJSON(res, 200, { templates: all });
|
|
1468
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
router.post('/api/awf/templates/import', async (req, res) => {
|
|
1472
|
+
try {
|
|
1473
|
+
const body = await parseBody(req);
|
|
1474
|
+
const tpl = BUILTIN_TEMPLATES.find(t => t.id === body.templateId)
|
|
1475
|
+
|| (await fetchRemoteTemplates()).find(t => t.id === body.templateId);
|
|
1476
|
+
if (!tpl) return sendError(res, 404, 'Template not found');
|
|
1477
|
+
const wf = {
|
|
1478
|
+
id: `wf_${Date.now()}`,
|
|
1479
|
+
name: tpl.name,
|
|
1480
|
+
description: tpl.description,
|
|
1481
|
+
nodes: JSON.parse(JSON.stringify(tpl.nodes)),
|
|
1482
|
+
edges: JSON.parse(JSON.stringify(tpl.edges)),
|
|
1483
|
+
enabled: false, // import disabled by default — user enables explicitly
|
|
1484
|
+
createdAt: new Date().toISOString(),
|
|
1485
|
+
importedFrom: tpl.id,
|
|
1486
|
+
};
|
|
1487
|
+
saveWorkflow(wf);
|
|
1488
|
+
sendJSON(res, 201, { ok: true, workflow: wf });
|
|
1489
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1493
|
+
// DEBUGGER — run history, replay, replay-from, AI explanation
|
|
1494
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1495
|
+
|
|
1496
|
+
// GET /api/awf/:id/run-history — list all saved run snapshots (newest first)
|
|
1497
|
+
router.get('/api/awf/:id/run-history', async (req, res) => {
|
|
1498
|
+
try {
|
|
1499
|
+
const id = req.params.id;
|
|
1500
|
+
const list = listRunSnapshots(id).map(ts => {
|
|
1501
|
+
const snap = loadRunSnapshot(id, ts);
|
|
1502
|
+
const lastError = snap?.steps?.find(s => s.error);
|
|
1503
|
+
return {
|
|
1504
|
+
ts,
|
|
1505
|
+
input: snap?.input || '',
|
|
1506
|
+
stepsCount: snap?.steps?.length || 0,
|
|
1507
|
+
hasError: !!lastError,
|
|
1508
|
+
errorNode: lastError?.nodeLabel,
|
|
1509
|
+
env: snap?.env || 'prod',
|
|
1510
|
+
};
|
|
1511
|
+
});
|
|
1512
|
+
sendJSON(res, 200, { runs: list });
|
|
1513
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
// GET /api/awf/:id/run/:ts — full snapshot of a past run (for replay UI)
|
|
1517
|
+
router.get('/api/awf/:id/run/:ts', async (req, res) => {
|
|
1518
|
+
try {
|
|
1519
|
+
const snap = loadRunSnapshot(req.params.id, req.params.ts);
|
|
1520
|
+
if (!snap) return sendError(res, 404, 'Run snapshot not found');
|
|
1521
|
+
sendJSON(res, 200, snap);
|
|
1522
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
// POST /api/awf/:id/replay/:ts — re-execute the workflow with the same input
|
|
1526
|
+
// (i.e. "run the same scenario again to see if the bug reproduces")
|
|
1527
|
+
router.post('/api/awf/:id/replay/:ts', async (req, res) => {
|
|
1528
|
+
try {
|
|
1529
|
+
const snap = loadRunSnapshot(req.params.id, req.params.ts);
|
|
1530
|
+
if (!snap) return sendError(res, 404, 'Run snapshot not found');
|
|
1531
|
+
const wf = listWorkflows().find(w => w.id === req.params.id);
|
|
1532
|
+
if (!wf) return sendError(res, 404, 'Workflow not found');
|
|
1533
|
+
const config = loadConfig();
|
|
1534
|
+
const steps = await runWorkflow(wf, snap.input, config, { triggerPayload: snap.triggerPayload });
|
|
1535
|
+
const newTs = saveRunSnapshot(wf.id, steps, {
|
|
1536
|
+
input: snap.input,
|
|
1537
|
+
triggerType: `replay:${req.params.ts}`,
|
|
1538
|
+
triggerPayload: snap.triggerPayload,
|
|
1539
|
+
wfSnapshot: { name: wf.name, nodes: wf.nodes, edges: wf.edges },
|
|
1540
|
+
});
|
|
1541
|
+
sendJSON(res, 200, { ok: true, ts: newTs, steps, paused: steps?.__paused || null });
|
|
1542
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
// POST /api/awf/:id/replay-from/:ts/:nodeId — re-execute starting from a
|
|
1546
|
+
// specific node, reusing the upstream outputs from the previous run.
|
|
1547
|
+
// Lets the user fix a node and re-test ONLY that branch without paying
|
|
1548
|
+
// for upstream API calls again.
|
|
1549
|
+
router.post('/api/awf/:id/replay-from/:ts/:nodeId', async (req, res) => {
|
|
1550
|
+
try {
|
|
1551
|
+
const snap = loadRunSnapshot(req.params.id, req.params.ts);
|
|
1552
|
+
if (!snap) return sendError(res, 404, 'Snapshot not found');
|
|
1553
|
+
const wf = listWorkflows().find(w => w.id === req.params.id);
|
|
1554
|
+
if (!wf) return sendError(res, 404, 'Workflow not found');
|
|
1555
|
+
const fromNodeId = req.params.nodeId;
|
|
1556
|
+
const fromNode = (wf.nodes || []).find(n => n.id === fromNodeId);
|
|
1557
|
+
if (!fromNode) return sendError(res, 404, 'Node not found in current workflow');
|
|
1558
|
+
// Build a synthetic context: take the previous run's output of the
|
|
1559
|
+
// predecessor node, feed it in as initial input.
|
|
1560
|
+
const predEdge = (wf.edges || []).find(e => e.to === fromNodeId);
|
|
1561
|
+
const predStep = predEdge ? snap.steps.find(s => s.nodeId === predEdge.from) : null;
|
|
1562
|
+
const seedInput = predStep?.output || snap.input;
|
|
1563
|
+
// Build a partial workflow rooted at fromNode
|
|
1564
|
+
const reachable = new Set([fromNodeId]);
|
|
1565
|
+
const queue = [fromNodeId];
|
|
1566
|
+
while (queue.length) {
|
|
1567
|
+
const cur = queue.shift();
|
|
1568
|
+
(wf.edges || []).filter(e => e.from === cur).forEach(e => {
|
|
1569
|
+
if (!reachable.has(e.to)) { reachable.add(e.to); queue.push(e.to); }
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
const partial = {
|
|
1573
|
+
...wf,
|
|
1574
|
+
nodes: (wf.nodes || []).filter(n => reachable.has(n.id)),
|
|
1575
|
+
edges: (wf.edges || []).filter(e => reachable.has(e.from) && reachable.has(e.to)),
|
|
1576
|
+
};
|
|
1577
|
+
// Make fromNode the "trigger" so the runner starts from it
|
|
1578
|
+
const config = loadConfig();
|
|
1579
|
+
const steps = await runWorkflow(partial, seedInput, config);
|
|
1580
|
+
// Merge with previous (preserve upstream history)
|
|
1581
|
+
const mergedSteps = [
|
|
1582
|
+
...snap.steps.filter(s => !reachable.has(s.nodeId)),
|
|
1583
|
+
...steps,
|
|
1584
|
+
];
|
|
1585
|
+
const newTs = saveRunSnapshot(wf.id, mergedSteps, {
|
|
1586
|
+
input: `replay-from:${fromNodeId}`,
|
|
1587
|
+
triggerType: `replay-from:${req.params.ts}:${fromNodeId}`,
|
|
1588
|
+
triggerPayload: snap.triggerPayload,
|
|
1589
|
+
wfSnapshot: { name: wf.name, nodes: wf.nodes, edges: wf.edges },
|
|
1590
|
+
});
|
|
1591
|
+
sendJSON(res, 200, { ok: true, ts: newTs, steps: mergedSteps });
|
|
1592
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
// GET /api/awf/:id/diff/:tsA/:tsB — compare two run snapshots node-by-node.
|
|
1596
|
+
// For each nodeId we surface whether the outputs are identical, differ, or
|
|
1597
|
+
// exist in only one of the two runs. Used by the UI to spot regressions.
|
|
1598
|
+
router.get('/api/awf/:id/diff/:tsA/:tsB', async (req, res) => {
|
|
1599
|
+
try {
|
|
1600
|
+
const a = loadRunSnapshot(req.params.id, req.params.tsA);
|
|
1601
|
+
const b = loadRunSnapshot(req.params.id, req.params.tsB);
|
|
1602
|
+
if (!a || !b) return sendError(res, 404, 'One or both snapshots not found');
|
|
1603
|
+
// Index steps by nodeId (use the LAST execution of each node, in case loops produced multiple).
|
|
1604
|
+
const indexOf = (snap) => {
|
|
1605
|
+
const m = new Map();
|
|
1606
|
+
for (const s of snap.steps || []) m.set(s.nodeId, s);
|
|
1607
|
+
return m;
|
|
1608
|
+
};
|
|
1609
|
+
const ia = indexOf(a);
|
|
1610
|
+
const ib = indexOf(b);
|
|
1611
|
+
const allNodeIds = new Set([...ia.keys(), ...ib.keys()]);
|
|
1612
|
+
const eq = (x, y) => {
|
|
1613
|
+
try { return JSON.stringify(x ?? null) === JSON.stringify(y ?? null); }
|
|
1614
|
+
catch { return String(x) === String(y); }
|
|
1615
|
+
};
|
|
1616
|
+
const diff = [];
|
|
1617
|
+
for (const nodeId of allNodeIds) {
|
|
1618
|
+
const sa = ia.get(nodeId);
|
|
1619
|
+
const sb = ib.get(nodeId);
|
|
1620
|
+
if (sa && !sb) diff.push({ nodeId, nodeLabel: sa.nodeLabel, status: 'removed', a: sa });
|
|
1621
|
+
else if (!sa && sb) diff.push({ nodeId, nodeLabel: sb.nodeLabel, status: 'added', b: sb });
|
|
1622
|
+
else if (sa && sb) {
|
|
1623
|
+
const outputEq = eq(sa.output, sb.output);
|
|
1624
|
+
const errorEq = eq(sa.error || null, sb.error || null);
|
|
1625
|
+
if (outputEq && errorEq) {
|
|
1626
|
+
diff.push({
|
|
1627
|
+
nodeId, nodeLabel: sa.nodeLabel, status: 'identical',
|
|
1628
|
+
durationDelta: (sb.durationMs || 0) - (sa.durationMs || 0),
|
|
1629
|
+
});
|
|
1630
|
+
} else {
|
|
1631
|
+
diff.push({
|
|
1632
|
+
nodeId, nodeLabel: sa.nodeLabel, status: 'changed',
|
|
1633
|
+
a: { output: sa.output, error: sa.error, durationMs: sa.durationMs },
|
|
1634
|
+
b: { output: sb.output, error: sb.error, durationMs: sb.durationMs },
|
|
1635
|
+
durationDelta: (sb.durationMs || 0) - (sa.durationMs || 0),
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
sendJSON(res, 200, {
|
|
1641
|
+
a: { ts: a.ts, errorCount: a.errorCount, totalDurationMs: a.totalDurationMs },
|
|
1642
|
+
b: { ts: b.ts, errorCount: b.errorCount, totalDurationMs: b.totalDurationMs },
|
|
1643
|
+
diff,
|
|
1644
|
+
summary: {
|
|
1645
|
+
identical: diff.filter(d => d.status === 'identical').length,
|
|
1646
|
+
changed: diff.filter(d => d.status === 'changed').length,
|
|
1647
|
+
added: diff.filter(d => d.status === 'added').length,
|
|
1648
|
+
removed: diff.filter(d => d.status === 'removed').length,
|
|
1649
|
+
},
|
|
1650
|
+
});
|
|
1651
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
// GET /api/awf/:id/paused-context/:key — return live ctx for a paused run.
|
|
1655
|
+
// `key` can be either the runId (live) or the snapshot ts (after persisting).
|
|
1656
|
+
// Used by the Variable Watcher panel to inspect ctx.output / ctx.input live.
|
|
1657
|
+
router.get('/api/awf/:id/paused-context/:key', async (req, res) => {
|
|
1658
|
+
try {
|
|
1659
|
+
const cacheKey = `${req.params.id}:${req.params.key}`;
|
|
1660
|
+
const entry = _pausedContexts.get(cacheKey);
|
|
1661
|
+
if (!entry) return sendError(res, 404, 'No paused context for this key');
|
|
1662
|
+
// Serialize safely — ctx values may be non-JSON-safe (Date, BigInt…).
|
|
1663
|
+
const safe = (v) => {
|
|
1664
|
+
try {
|
|
1665
|
+
if (v === null || typeof v !== 'object') return v;
|
|
1666
|
+
return JSON.parse(JSON.stringify(v));
|
|
1667
|
+
} catch { return String(v); }
|
|
1668
|
+
};
|
|
1669
|
+
sendJSON(res, 200, {
|
|
1670
|
+
atNodeId: entry.atNodeId,
|
|
1671
|
+
nodeConfig: entry.nodeConfig,
|
|
1672
|
+
pendingNodes: entry.pendingNodes,
|
|
1673
|
+
ageMs: Date.now() - entry.timestamp,
|
|
1674
|
+
ctx: {
|
|
1675
|
+
output: safe(entry.ctxSnapshot?.output),
|
|
1676
|
+
input: safe(entry.ctxSnapshot?.input),
|
|
1677
|
+
loopItem: safe(entry.ctxSnapshot?.loopItem),
|
|
1678
|
+
},
|
|
1679
|
+
});
|
|
1680
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
// POST /api/awf/:id/resume/:ts — resume a paused (breakpoint-halted) run
|
|
1684
|
+
// from the node where it stopped. Skips the breakpoint on that one node so
|
|
1685
|
+
// the runner proceeds past it. The user can re-pause on any other bp.
|
|
1686
|
+
router.post('/api/awf/:id/resume/:ts', async (req, res) => {
|
|
1687
|
+
try {
|
|
1688
|
+
const snap = loadRunSnapshot(req.params.id, req.params.ts);
|
|
1689
|
+
if (!snap) return sendError(res, 404, 'Snapshot not found');
|
|
1690
|
+
if (!snap.paused) return sendError(res, 400, 'Run is not paused');
|
|
1691
|
+
const wf = listWorkflows().find(w => w.id === req.params.id);
|
|
1692
|
+
if (!wf) return sendError(res, 404, 'Workflow not found');
|
|
1693
|
+
const config = loadConfig();
|
|
1694
|
+
const pausedNode = snap.paused.atNodeId;
|
|
1695
|
+
const seed = snap.paused.ctxSnapshot?.output ?? snap.input;
|
|
1696
|
+
// Build a sub-graph starting from the paused node
|
|
1697
|
+
const reachable = new Set([pausedNode]);
|
|
1698
|
+
const queue = [pausedNode];
|
|
1699
|
+
while (queue.length) {
|
|
1700
|
+
const cur = queue.shift();
|
|
1701
|
+
(wf.edges || []).filter(e => e.from === cur).forEach(e => {
|
|
1702
|
+
if (!reachable.has(e.to)) { reachable.add(e.to); queue.push(e.to); }
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
const partial = {
|
|
1706
|
+
...wf,
|
|
1707
|
+
nodes: (wf.nodes || []).filter(n => reachable.has(n.id)),
|
|
1708
|
+
edges: (wf.edges || []).filter(e => reachable.has(e.from) && reachable.has(e.to)),
|
|
1709
|
+
};
|
|
1710
|
+
const steps = await runWorkflow(partial, seed, config, { skipBreakpointFor: [pausedNode] });
|
|
1711
|
+
const merged = [
|
|
1712
|
+
...snap.steps.filter(s => !s.paused), // drop the original ⏸ marker
|
|
1713
|
+
...steps,
|
|
1714
|
+
];
|
|
1715
|
+
const newTs = saveRunSnapshot(wf.id, merged, {
|
|
1716
|
+
input: `resume:${req.params.ts}`,
|
|
1717
|
+
triggerType: `resume:${req.params.ts}`,
|
|
1718
|
+
triggerPayload: snap.triggerPayload,
|
|
1719
|
+
wfSnapshot: { name: wf.name, nodes: wf.nodes, edges: wf.edges },
|
|
1720
|
+
});
|
|
1721
|
+
sendJSON(res, 200, { ok: true, ts: newTs, steps: merged, paused: steps?.__paused || null });
|
|
1722
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
// POST /api/awf/:id/explain/:ts/:nodeId — AI plain-language explanation of
|
|
1726
|
+
// what happened at a given node. Used by the debugger to translate stack
|
|
1727
|
+
// traces and JSON outputs into Italian sentences a non-dev can understand.
|
|
1728
|
+
router.post('/api/awf/:id/explain/:ts/:nodeId', async (req, res) => {
|
|
1729
|
+
try {
|
|
1730
|
+
const snap = loadRunSnapshot(req.params.id, req.params.ts);
|
|
1731
|
+
if (!snap) return sendError(res, 404, 'Snapshot not found');
|
|
1732
|
+
const step = snap.steps.find(s => s.nodeId === req.params.nodeId);
|
|
1733
|
+
if (!step) return sendError(res, 404, 'Step not found in snapshot');
|
|
1734
|
+
const wf = listWorkflows().find(w => w.id === req.params.id);
|
|
1735
|
+
const node = wf?.nodes?.find(n => n.id === req.params.nodeId);
|
|
1736
|
+
const sysPrompt =
|
|
1737
|
+
'Spieghi a un non-programmatore italiano cosa è successo in un nodo di un workflow. ' +
|
|
1738
|
+
'Massimo 3 frasi. Diretto, concreto. Se c\'è un errore, spiega CAUSA (in italiano semplice) e UNA AZIONE per risolverlo. ' +
|
|
1739
|
+
'Se è andato OK, descrivi cosa ha prodotto in 1 frase.';
|
|
1740
|
+
const userMsg = JSON.stringify({
|
|
1741
|
+
node: { type: node?.defId, label: step.nodeLabel, config: node?.config },
|
|
1742
|
+
output: typeof step.output === 'string' ? step.output.slice(0, 1500) : JSON.stringify(step.output).slice(0, 1500),
|
|
1743
|
+
error: step.error || null,
|
|
1744
|
+
});
|
|
1745
|
+
const explanation = await callLLM(loadConfig(), sysPrompt, userMsg, { temperature: 0.2, maxTokens: 250 });
|
|
1746
|
+
sendJSON(res, 200, { explanation: explanation.trim() });
|
|
1747
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
// POST /api/awf/:id/breakpoints — set/clear breakpoints on a workflow
|
|
1751
|
+
router.post('/api/awf/:id/breakpoints', async (req, res) => {
|
|
1752
|
+
try {
|
|
1753
|
+
const id = req.params.id;
|
|
1754
|
+
const body = await parseBody(req);
|
|
1755
|
+
const wf = listWorkflows().find(w => w.id === id);
|
|
1756
|
+
if (!wf) return sendError(res, 404, 'Workflow not found');
|
|
1757
|
+
wf.breakpoints = Array.isArray(body.breakpoints) ? body.breakpoints : [];
|
|
1758
|
+
saveWorkflow(wf);
|
|
1759
|
+
sendJSON(res, 200, { ok: true, breakpoints: wf.breakpoints });
|
|
1760
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1764
|
+
// CREDENTIALS — multi-environment switch (dev/staging/prod)
|
|
1765
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1766
|
+
router.get('/api/awf/env', async (_req, res) => {
|
|
1767
|
+
sendJSON(res, 200, { env: _currentEnv() });
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
router.post('/api/awf/env', async (req, res) => {
|
|
1771
|
+
try {
|
|
1772
|
+
const body = await parseBody(req);
|
|
1773
|
+
const newEnv = String(body.env || 'prod').toLowerCase();
|
|
1774
|
+
if (!['dev', 'staging', 'prod'].includes(newEnv)) return sendError(res, 400, 'env must be dev|staging|prod');
|
|
1775
|
+
process.env.NHA_ENV = newEnv;
|
|
1776
|
+
sendJSON(res, 200, { ok: true, env: newEnv });
|
|
1777
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1781
|
+
// WORKSPACE SYNC + AUTO-BACKUP (Google Drive)
|
|
1782
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
1783
|
+
router.post('/api/awf/backup', async (req, res) => {
|
|
1784
|
+
try {
|
|
1785
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
1786
|
+
const result = await backupWorkflowsToDrive(loadConfig(), { folder: body.folder });
|
|
1787
|
+
sendJSON(res, 200, result);
|
|
1788
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
// POST /api/awf/sync/push — push the current workspace (all workflows) to
|
|
1792
|
+
// Google Drive as a single backup JSON. Differs from /backup in that it
|
|
1793
|
+
// also returns a sync-pointer file id so the user can /pull it later from
|
|
1794
|
+
// another machine.
|
|
1795
|
+
router.post('/api/awf/sync/push', async (req, res) => {
|
|
1796
|
+
try {
|
|
1797
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
1798
|
+
const result = await backupWorkflowsToDrive(loadConfig(), {
|
|
1799
|
+
folder: body.folder,
|
|
1800
|
+
syncTag: true, // mark filename so /pull can find it deterministically
|
|
1801
|
+
});
|
|
1802
|
+
sendJSON(res, 200, { ok: true, ...result, pushedAt: new Date().toISOString() });
|
|
1803
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
// POST /api/awf/sync/pull — pull the latest workspace backup from Drive
|
|
1807
|
+
// and merge into local workflows. Conflict resolution: `mode` decides:
|
|
1808
|
+
// - 'merge' (default): keep local, only import workflows missing locally
|
|
1809
|
+
// - 'replace': overwrite every workflow with the Drive version
|
|
1810
|
+
router.post('/api/awf/sync/pull', async (req, res) => {
|
|
1811
|
+
try {
|
|
1812
|
+
const body = await parseBody(req).catch(() => ({}));
|
|
1813
|
+
const config = loadConfig();
|
|
1814
|
+
const accessToken = config.google?.tokens?.access_token;
|
|
1815
|
+
if (!accessToken) return sendError(res, 400, 'Google Drive not authorized');
|
|
1816
|
+
|
|
1817
|
+
// Find the most recent nha-awf-sync-*.json in Drive
|
|
1818
|
+
const q = encodeURIComponent("name contains 'nha-awf-sync-' and mimeType='application/json' and trashed=false");
|
|
1819
|
+
const list = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&orderBy=createdTime desc&pageSize=1&fields=files(id,name,createdTime)`, {
|
|
1820
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1821
|
+
}).then(r => r.json()).catch(() => null);
|
|
1822
|
+
const fileId = list?.files?.[0]?.id;
|
|
1823
|
+
if (!fileId) return sendError(res, 404, 'No nha-awf-sync-* file found on Drive');
|
|
1824
|
+
|
|
1825
|
+
const dl = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
|
|
1826
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1827
|
+
});
|
|
1828
|
+
if (!dl.ok) return sendError(res, 500, `Drive download failed: ${dl.status}`);
|
|
1829
|
+
const payload = await dl.json();
|
|
1830
|
+
if (!Array.isArray(payload.workflows)) return sendError(res, 400, 'Backup payload invalid');
|
|
1831
|
+
|
|
1832
|
+
const mode = body.mode === 'replace' ? 'replace' : 'merge';
|
|
1833
|
+
const existing = new Set(listWorkflows().map(w => w.id));
|
|
1834
|
+
let imported = 0, skipped = 0;
|
|
1835
|
+
for (const wf of payload.workflows) {
|
|
1836
|
+
if (mode === 'merge' && existing.has(wf.id)) { skipped++; continue; }
|
|
1837
|
+
try { saveWorkflow(wf); imported++; } catch {}
|
|
1838
|
+
}
|
|
1839
|
+
sendJSON(res, 200, { ok: true, mode, imported, skipped, total: payload.workflows.length, pulledFrom: list.files[0].name });
|
|
1840
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
router.post('/api/awf/restore', async (req, res) => {
|
|
1844
|
+
try {
|
|
1845
|
+
const body = await parseBody(req);
|
|
1846
|
+
if (!body.payload) return sendError(res, 400, 'payload (parsed backup JSON) required');
|
|
1847
|
+
const payload = typeof body.payload === 'string' ? JSON.parse(body.payload) : body.payload;
|
|
1848
|
+
if (!Array.isArray(payload.workflows)) return sendError(res, 400, 'invalid backup format');
|
|
1849
|
+
let imported = 0;
|
|
1850
|
+
for (const wf of payload.workflows) {
|
|
1851
|
+
try { saveWorkflow(wf); imported++; } catch {}
|
|
1852
|
+
}
|
|
1853
|
+
// Credentials restored only if user explicitly opts in (security)
|
|
1854
|
+
if (body.restoreCredentials && payload.credentialsEncrypted) {
|
|
1855
|
+
// Will only decrypt on the SAME machine (machine-bound salt).
|
|
1856
|
+
fs.writeFileSync(CREDS_FILE, JSON.stringify(payload.credentialsEncrypted, null, 2));
|
|
1857
|
+
}
|
|
1858
|
+
sendJSON(res, 200, { ok: true, imported, total: payload.workflows.length });
|
|
1859
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
1860
|
+
});
|
|
729
1861
|
}
|