loopengine 0.1.66 → 0.1.68
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/README.md +28 -0
- package/dist/core/agent-config.d.ts +3 -0
- package/dist/core/approval-links.d.ts +16 -0
- package/dist/core/approval-links.js +76 -0
- package/dist/core/durable-approvals.d.ts +15 -0
- package/dist/core/durable-approvals.js +61 -3
- package/dist/core/http-notifier.d.ts +16 -1
- package/dist/core/http-notifier.js +49 -6
- package/dist/core/http-notify-triggers/slack.d.ts +21 -0
- package/dist/core/http-notify-triggers/slack.js +49 -10
- package/dist/core/http-notify-triggers/webhook.d.ts +19 -10
- package/dist/core/http-notify-triggers/webhook.js +84 -31
- package/dist/core/notifier-deliveries.d.ts +18 -0
- package/dist/core/notifier-deliveries.js +22 -0
- package/dist/core/notifier-file.d.ts +35 -0
- package/dist/core/notifier-file.js +104 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/web/agents-config-page.js +248 -2
- package/dist/web/console-shell.js +12 -4
- package/dist/web/decide-page.d.ts +16 -0
- package/dist/web/decide-page.js +138 -0
- package/dist/web/notifier-admin.d.ts +63 -0
- package/dist/web/notifier-admin.js +91 -0
- package/package.json +7 -1
|
@@ -39,6 +39,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
39
39
|
panels' own content. */
|
|
40
40
|
#empty { color: var(--ink-muted); font-size: 13px; padding: 24px; }
|
|
41
41
|
.env-cell { display: grid; gap: 6px; }
|
|
42
|
+
.approvals-nudge { border: 1px solid var(--warn); background: var(--warn-soft); color: var(--ink); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 24px; font-size: 13px; }
|
|
42
43
|
.env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
43
44
|
form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
|
|
44
45
|
form.add-source.env-inline input, form.add-source.env-inline select { flex: 1 1 140px; width: auto; min-width: 0; }
|
|
@@ -889,7 +890,9 @@ ${consoleShellScript}
|
|
|
889
890
|
// opened. ----
|
|
890
891
|
|
|
891
892
|
function renderActauthTabPlaceholder() {
|
|
892
|
-
return '<
|
|
893
|
+
return '<div id="pendingContent"></div>' +
|
|
894
|
+
'<section id="actauthSection"><div id="actauthContent"><p class="hint">Loading…</p></div></section>' +
|
|
895
|
+
'<div id="approvalsContent"><p class="hint">Loading…</p></div>';
|
|
893
896
|
}
|
|
894
897
|
|
|
895
898
|
function renderActauthRuleRow(r) {
|
|
@@ -953,6 +956,246 @@ ${consoleShellScript}
|
|
|
953
956
|
'</form></section>';
|
|
954
957
|
}
|
|
955
958
|
|
|
959
|
+
// ---- Permissions → "Where approval requests and questions are sent": where "ask" decisions go when nobody is
|
|
960
|
+
// in Chat (web/notifier-admin.ts behind GET/PUT/DELETE .../notifier),
|
|
961
|
+
// the decisions still waiting (GET .../pending), and recent webhook
|
|
962
|
+
// deliveries. Reloaded every time the tab opens, so the waiting list is
|
|
963
|
+
// current. ----
|
|
964
|
+
|
|
965
|
+
var NOTIFIER_EVENTS = [
|
|
966
|
+
['approval', 'Approvals'], ['question', 'Questions'], ['run_finish', 'Run finished'], ['run_start', 'Run started']
|
|
967
|
+
];
|
|
968
|
+
|
|
969
|
+
function approvalsContentEl() {
|
|
970
|
+
return detail.querySelector('#approvalsContent');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function renderPendingTable(pending) {
|
|
974
|
+
if (!pending.length) return '<p class="hint">Nothing is waiting for a decision.</p>';
|
|
975
|
+
return '<div class="le-table-wrap"><table><thead><tr><th>Waiting</th><th>Details</th><th>Session</th><th></th></tr></thead><tbody>' +
|
|
976
|
+
pending.map(function (p) {
|
|
977
|
+
var details = p.kind === 'question'
|
|
978
|
+
? escapeHtml((p.args && p.args.question) || p.reason)
|
|
979
|
+
: '<code>' + escapeHtml(p.tool) + '</code> <span class="hint">' + escapeHtml(JSON.stringify(p.args)) + '</span>';
|
|
980
|
+
var actions = p.kind === 'question'
|
|
981
|
+
? '<form class="pending-answer" data-id="' + escapeHtml(p.pendingId) + '" style="display:flex;gap:6px"><input name="answer" placeholder="Answer" required style="min-width:0"><button type="submit">Answer</button></form>'
|
|
982
|
+
: '<div style="display:flex;gap:6px"><button type="button" class="primary pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="approve">Approve</button>' +
|
|
983
|
+
'<button type="button" class="pending-decide" data-id="' + escapeHtml(p.pendingId) + '" data-decision="deny">Deny</button></div>';
|
|
984
|
+
if (p.decidePath) {
|
|
985
|
+
actions += '<button type="button" class="pending-copy" data-path="' + escapeHtml(p.decidePath) + '" title="A link anyone can open to decide, no login needed" style="margin-top:6px">Copy link</button>';
|
|
986
|
+
}
|
|
987
|
+
return '<tr><td><span class="le-pill ' + (p.kind === 'question' ? 'accent' : 'ask') + '">' + (p.kind === 'question' ? 'question' : 'approval') + '</span>' +
|
|
988
|
+
(p.createdAt ? '<div class="hint">' + escapeHtml(new Date(p.createdAt).toLocaleString()) + '</div>' : '') + '</td>' +
|
|
989
|
+
'<td>' + details + '</td><td><code>' + escapeHtml(p.sessionId) + '</code></td><td>' + actions + '</td></tr>';
|
|
990
|
+
}).join('') + '</tbody></table></div>';
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function renderNotifierStatus(n) {
|
|
994
|
+
if (n.source === 'code') return 'Set in this agent's <code>index.ts</code> (<code>httpNotifier</code>, ' + escapeHtml(n.channel) + '). Edit it there; code always wins over the form below.';
|
|
995
|
+
if (n.source === 'file') return 'Sending to <strong>' + escapeHtml(n.channel) + '</strong> for: ' + escapeHtml((n.events || []).join(', ')) + '.';
|
|
996
|
+
if (n.missingSecret) return 'Saved, but not sending yet: set the secret below (<code>' + escapeHtml(n.missingSecret) + '</code>).';
|
|
997
|
+
if (n.source === 'default') return 'Using the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + '). Set one below to use a different target for this agent.';
|
|
998
|
+
return '<strong>Nowhere yet</strong>, so nobody is notified. Choose a webhook, Slack channel or Lark chat below.';
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function renderNotifierForm(n) {
|
|
1002
|
+
var f = n.file || { channel: 'webhook', events: ['approval', 'question'] };
|
|
1003
|
+
var events = f.events || [];
|
|
1004
|
+
function eventBox(e) {
|
|
1005
|
+
return '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="events" value="' + e[0] + '"' + (events.indexOf(e[0]) !== -1 ? ' checked' : '') + '> ' + e[1] + '</label>';
|
|
1006
|
+
}
|
|
1007
|
+
function field(channel, html) {
|
|
1008
|
+
return '<div class="notifier-fields" data-channel="' + channel + '"' + (f.channel === channel ? '' : ' hidden') + '>' + html + '</div>';
|
|
1009
|
+
}
|
|
1010
|
+
function secretField(channel, label) {
|
|
1011
|
+
var isSet = n.secretsSet && n.secretsSet[channel];
|
|
1012
|
+
return '<label>' + label + ' <span class="hint">(' + (isSet ? 'set — leave blank to keep it' : 'not set') + '; stored in this agent's env as ' + escapeHtml(n.secretVars[channel]) + ')</span>' +
|
|
1013
|
+
'<span style="display:flex;gap:6px"><input type="password" name="secret_' + channel + '" autocomplete="off" style="flex:1;min-width:0">' +
|
|
1014
|
+
(channel === 'webhook' ? '<button type="button" class="notifier-generate">Generate</button>' : '') + '</span></label>';
|
|
1015
|
+
}
|
|
1016
|
+
return '<form class="add-source" id="notifierForm" style="max-width:560px">' +
|
|
1017
|
+
'<label>Send to<select name="channel" id="notifierChannel">' +
|
|
1018
|
+
['webhook', 'slack', 'lark'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat' }[c] + '</option>'; }).join('') +
|
|
1019
|
+
'</select></label>' +
|
|
1020
|
+
field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret')) +
|
|
1021
|
+
field('slack', '<label>Channel ID<input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' + secretField('slack', 'Bot token')) +
|
|
1022
|
+
field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
|
|
1023
|
+
'<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
|
|
1024
|
+
'<input type="url" name="publicUrl" id="notifierPublicUrl" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
|
|
1025
|
+
'<div class="body-field">Send' +
|
|
1026
|
+
'<div style="display:flex;gap:14px;flex-wrap:wrap">' + NOTIFIER_EVENTS.slice(0, 2).map(eventBox).join('') + '</div>' +
|
|
1027
|
+
'<details' + (events.indexOf('run_finish') !== -1 || events.indexOf('run_start') !== -1 ? ' open' : '') + '><summary>More events</summary>' +
|
|
1028
|
+
'<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px">' + NOTIFIER_EVENTS.slice(2).map(eventBox).join('') + '</div>' +
|
|
1029
|
+
'<p class="hint" style="margin:4px 0 0">Run finished tells your app the agent's final reply once a decision lets it finish.</p></details></div>' +
|
|
1030
|
+
(n.canSave === false ? '<p class="error" style="margin:0">Saving needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server, because this stores a secret. Add <code>LOOPENGINE_ADMIN_AUTH=user:pass</code> to <code>.env</code> and restart.</p>' : '') +
|
|
1031
|
+
'<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit"' + (n.canSave === false ? ' disabled' : '') + '>Save</button><button type="button" id="notifierTestBtn">Send test</button>' +
|
|
1032
|
+
(n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
|
|
1033
|
+
'<div id="notifierResult" class="hint"></div>' +
|
|
1034
|
+
'<p class="hint" style="margin:0">For a webhook: your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>' +
|
|
1035
|
+
'</form>';
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function renderDeliveries(list) {
|
|
1039
|
+
if (!list.length) return '<p class="hint">No deliveries since the server started.</p>';
|
|
1040
|
+
return '<div class="le-table-wrap"><table><thead><tr><th>When</th><th>What</th><th>Attempt</th><th>Result</th></tr></thead><tbody>' +
|
|
1041
|
+
list.slice(0, 15).map(function (d) {
|
|
1042
|
+
return '<tr><td>' + escapeHtml(new Date(d.at).toLocaleTimeString()) + '</td><td>' + escapeHtml(d.kind) + '</td><td>' + d.attempt + '</td>' +
|
|
1043
|
+
'<td>' + (d.ok ? '<span class="le-pill good">' + (d.status || 'ok') + '</span>' : '<span class="le-pill bad">' + escapeHtml(d.error || 'failed') + '</span>') + '</td></tr>';
|
|
1044
|
+
}).join('') + '</tbody></table></div>';
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Tools that can stop for approval: a rule that asks, or "ask" as the
|
|
1048
|
+
// decision when no rule matches.
|
|
1049
|
+
function askingTools() {
|
|
1050
|
+
var perms = currentCfg && currentCfg.permissions;
|
|
1051
|
+
if (!perms) return [];
|
|
1052
|
+
var tools = [];
|
|
1053
|
+
perms.rules.forEach(function (r) { if (r.decision === 'ask' && tools.indexOf(r.tool) === -1) tools.push(r.tool); });
|
|
1054
|
+
if (perms.defaultDecision === 'ask') tools.push('any tool without a rule');
|
|
1055
|
+
return tools;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// The top of the tab: a nudge when approvals over HTTP would reach
|
|
1059
|
+
// nobody, and whatever is waiting right now.
|
|
1060
|
+
function renderPendingHtml(pending, n) {
|
|
1061
|
+
var html = '';
|
|
1062
|
+
var tools = askingTools();
|
|
1063
|
+
if (tools.length && n && n.source === 'none') {
|
|
1064
|
+
html += '<div class="approvals-nudge"><strong>Approval requests aren't sent anywhere yet.</strong> ' +
|
|
1065
|
+
escapeHtml(tools.slice(0, 3).join(', ')) + (tools.length > 3 ? ' and more' : '') + ' can stop for approval, but when an app calls this agent over HTTP nobody is notified. ' +
|
|
1066
|
+
'<a href="#approvalsSetup" class="approvals-nudge-link">Choose where they go</a></div>';
|
|
1067
|
+
}
|
|
1068
|
+
if (pending.length) html += '<section><h3>Waiting for a decision (' + pending.length + ')</h3>' + renderPendingTable(pending) + '</section>';
|
|
1069
|
+
return html;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function renderApprovalsHtml(n) {
|
|
1073
|
+
return '<section id="approvalsSetup"><h3>Where approval requests and questions are sent</h3>' +
|
|
1074
|
+
'<p class="muted" style="margin:0 0 8px">When an app calls this agent over HTTP and a tool call needs approval, or the agent asks a question, it's sent here. In Chat it shows up in the conversation instead.</p>' +
|
|
1075
|
+
'<p class="muted" style="margin:0 0 12px">Currently: ' + renderNotifierStatus(n) + '</p>' +
|
|
1076
|
+
(n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
|
|
1077
|
+
(n.source === 'code' ? '' : renderNotifierForm(n)) + '</section>' +
|
|
1078
|
+
'<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function wirePendingHandlers(name) {
|
|
1082
|
+
var content = detail.querySelector('#pendingContent');
|
|
1083
|
+
if (!content) return;
|
|
1084
|
+
var nudgeLink = content.querySelector('.approvals-nudge-link');
|
|
1085
|
+
if (nudgeLink) {
|
|
1086
|
+
nudgeLink.addEventListener('click', function (ev) {
|
|
1087
|
+
ev.preventDefault();
|
|
1088
|
+
var target = detail.querySelector('#approvalsSetup');
|
|
1089
|
+
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
var copyBtns = content.querySelectorAll('.pending-copy');
|
|
1093
|
+
for (var c = 0; c < copyBtns.length; c++) {
|
|
1094
|
+
copyBtns[c].addEventListener('click', function (ev) {
|
|
1095
|
+
var btn = ev.currentTarget;
|
|
1096
|
+
var link = location.origin + btn.dataset.path;
|
|
1097
|
+
var done = function () { btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = 'Copy link'; }, 1500); };
|
|
1098
|
+
if (navigator.clipboard) navigator.clipboard.writeText(link).then(done, function () { window.prompt('Copy this link', link); });
|
|
1099
|
+
else window.prompt('Copy this link', link);
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
var decideBtns = content.querySelectorAll('.pending-decide');
|
|
1103
|
+
for (var i = 0; i < decideBtns.length; i++) {
|
|
1104
|
+
decideBtns[i].addEventListener('click', function (ev) {
|
|
1105
|
+
var btn = ev.currentTarget;
|
|
1106
|
+
btn.disabled = true;
|
|
1107
|
+
fetchJsonRetryOnce('/pending-approvals/' + encodeURIComponent(btn.dataset.id) + '/resolve', {
|
|
1108
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ decision: btn.dataset.decision }),
|
|
1109
|
+
}).then(function () { loadApprovalsSection(name); });
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
var answerForms = content.querySelectorAll('.pending-answer');
|
|
1113
|
+
for (var j = 0; j < answerForms.length; j++) {
|
|
1114
|
+
answerForms[j].addEventListener('submit', function (ev) {
|
|
1115
|
+
ev.preventDefault();
|
|
1116
|
+
var form = ev.currentTarget;
|
|
1117
|
+
fetchJsonRetryOnce('/pending-questions/' + encodeURIComponent(form.dataset.id) + '/answer', {
|
|
1118
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ answer: new FormData(form).get('answer') }),
|
|
1119
|
+
}).then(function () { loadApprovalsSection(name); });
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function wireApprovalsHandlers(name) {
|
|
1125
|
+
var content = approvalsContentEl();
|
|
1126
|
+
if (!content) return;
|
|
1127
|
+
var form = content.querySelector('#notifierForm');
|
|
1128
|
+
if (!form) return;
|
|
1129
|
+
var result = content.querySelector('#notifierResult');
|
|
1130
|
+
content.querySelector('#notifierChannel').addEventListener('change', function (ev) {
|
|
1131
|
+
var groups = form.querySelectorAll('.notifier-fields');
|
|
1132
|
+
for (var k = 0; k < groups.length; k++) groups[k].hidden = groups[k].dataset.channel !== ev.currentTarget.value;
|
|
1133
|
+
});
|
|
1134
|
+
var generate = form.querySelector('.notifier-generate');
|
|
1135
|
+
if (generate) {
|
|
1136
|
+
generate.addEventListener('click', function () {
|
|
1137
|
+
var bytes = new Uint8Array(24);
|
|
1138
|
+
crypto.getRandomValues(bytes);
|
|
1139
|
+
var input = form.querySelector('input[name="secret_webhook"]');
|
|
1140
|
+
input.type = 'text';
|
|
1141
|
+
input.value = Array.prototype.map.call(bytes, function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
|
|
1142
|
+
result.textContent = 'Copy this secret into your app before saving; it is not shown again.';
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
form.addEventListener('submit', function (ev) {
|
|
1146
|
+
ev.preventDefault();
|
|
1147
|
+
var data = new FormData(form);
|
|
1148
|
+
var channel = data.get('channel');
|
|
1149
|
+
var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId'), publicUrl: data.get('publicUrl') };
|
|
1150
|
+
var secret = data.get('secret_' + channel);
|
|
1151
|
+
if (secret) body.secret = secret;
|
|
1152
|
+
result.textContent = 'Saving…';
|
|
1153
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
|
|
1154
|
+
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
1155
|
+
}).then(function (r) {
|
|
1156
|
+
if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
|
|
1157
|
+
loadApprovalsSection(name, 'Saved.');
|
|
1158
|
+
});
|
|
1159
|
+
});
|
|
1160
|
+
content.querySelector('#notifierTestBtn').addEventListener('click', function () {
|
|
1161
|
+
result.textContent = 'Sending…';
|
|
1162
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/test', { method: 'POST' }).then(function (r) {
|
|
1163
|
+
var t = r.body;
|
|
1164
|
+
loadApprovalsSection(name, t.ok ? 'Test delivered (' + (t.status || 'ok') + ').' : 'Test failed: ' + (t.error || 'unknown error'));
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
var removeBtn = content.querySelector('#notifierRemoveBtn');
|
|
1168
|
+
if (removeBtn) {
|
|
1169
|
+
removeBtn.addEventListener('click', function () {
|
|
1170
|
+
if (removeBtn.dataset.confirming !== '1') { removeBtn.dataset.confirming = '1'; removeBtn.textContent = 'Click again to remove'; return; }
|
|
1171
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', { method: 'DELETE' }).then(function () { loadApprovalsSection(name, 'Removed.'); });
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function loadApprovalsSection(name, message) {
|
|
1177
|
+
var content = approvalsContentEl();
|
|
1178
|
+
if (!content) return;
|
|
1179
|
+
Promise.all([
|
|
1180
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier'),
|
|
1181
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/pending'),
|
|
1182
|
+
]).then(function (results) {
|
|
1183
|
+
if (currentName !== name) return;
|
|
1184
|
+
if (!results[0].ok) throw new Error(results[0].body.error || 'request failed');
|
|
1185
|
+
content.innerHTML = renderApprovalsHtml(results[0].body);
|
|
1186
|
+
wireApprovalsHandlers(name);
|
|
1187
|
+
var pendingEl = detail.querySelector('#pendingContent');
|
|
1188
|
+
if (pendingEl) {
|
|
1189
|
+
pendingEl.innerHTML = renderPendingHtml((results[1].ok && results[1].body.pending) || [], results[0].body);
|
|
1190
|
+
wirePendingHandlers(name);
|
|
1191
|
+
}
|
|
1192
|
+
if (message) { var r = content.querySelector('#notifierResult'); if (r) r.textContent = message; }
|
|
1193
|
+
}).catch(function (err) {
|
|
1194
|
+
if (currentName !== name) return;
|
|
1195
|
+
content.innerHTML = '<p class="error">Could not load approvals: ' + escapeHtml(err.message) + '</p>';
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
956
1199
|
function actauthContentEl() {
|
|
957
1200
|
return detail.querySelector('#actauthContent');
|
|
958
1201
|
}
|
|
@@ -2448,6 +2691,7 @@ ${consoleShellScript}
|
|
|
2448
2691
|
if (tab === 'actauth' && actauthLoadedFor !== currentName) {
|
|
2449
2692
|
loadActauthTab(currentName);
|
|
2450
2693
|
}
|
|
2694
|
+
if (tab === 'actauth') loadApprovalsSection(currentName);
|
|
2451
2695
|
if (tab === 'env' && envLoadedFor !== currentName) {
|
|
2452
2696
|
loadEnvTab(currentName);
|
|
2453
2697
|
}
|
|
@@ -2609,7 +2853,9 @@ ${consoleShellScript}
|
|
|
2609
2853
|
currentTab = CONFIG_TABS.indexOf(initialParams.get('tab')) === -1 ? 'overview' : initialParams.get('tab');
|
|
2610
2854
|
leShell.ready.then(function (agents) {
|
|
2611
2855
|
if (!agents.length) {
|
|
2612
|
-
empty.innerHTML =
|
|
2856
|
+
empty.innerHTML = leShell.loadError()
|
|
2857
|
+
? 'Could not load agents: ' + escapeHtml(leShell.loadError()) + '. Reload the page to try again.'
|
|
2858
|
+
: 'No agents yet. <a href="/agents?new=1">Create one</a> or add a folder under agents/.';
|
|
2613
2859
|
return;
|
|
2614
2860
|
}
|
|
2615
2861
|
var requested = initialParams.get('agent');
|
|
@@ -98,15 +98,23 @@ export const consoleShellScript = `<script>
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
var
|
|
102
|
-
|
|
101
|
+
var loadError = null;
|
|
102
|
+
var ready = fetch('/agents', { headers: { accept: 'application/json' } })
|
|
103
|
+
.then(function (r) {
|
|
104
|
+
if (!r.ok) throw new Error(r.status === 401 ? 'sign-in required (HTTP 401)' : 'HTTP ' + r.status);
|
|
105
|
+
return r.json();
|
|
106
|
+
})
|
|
103
107
|
.then(function (data) {
|
|
104
108
|
agents = data.agents || [];
|
|
105
109
|
renderList();
|
|
106
110
|
loadToSet();
|
|
107
111
|
return agents;
|
|
108
112
|
})
|
|
109
|
-
.catch(function () {
|
|
113
|
+
.catch(function (err) {
|
|
114
|
+
loadError = err.message;
|
|
115
|
+
if (list) list.innerHTML = '<li class="le-agent-item"><span></span><span class="le-meta">Could not load agents: ' + esc(err.message) + '</span><span></span></li>';
|
|
116
|
+
return [];
|
|
117
|
+
});
|
|
110
118
|
|
|
111
119
|
// opts: { name, prompt, chips: [{ text, mono }], tab, counts: { tools, skills, abilities, actauth } }
|
|
112
120
|
function renderHead(opts) {
|
|
@@ -160,6 +168,6 @@ export const consoleShellScript = `<script>
|
|
|
160
168
|
if (app.classList.contains('nav-open') && !ev.target.closest('.le-sidebar')) app.classList.remove('nav-open');
|
|
161
169
|
});
|
|
162
170
|
|
|
163
|
-
window.leShell = { ready: ready, renderHead: renderHead, setTab: setTab, setActive: setActive, esc: esc, tabHref: tabHref, envCountHtml: envCountHtml, setCount: setCount };
|
|
171
|
+
window.leShell = { loadError: function () { return loadError; }, ready: ready, renderHead: renderHead, setTab: setTab, setActive: setActive, esc: esc, tabHref: tabHref, envCountHtml: envCountHtml, setCount: setCount };
|
|
164
172
|
})();
|
|
165
173
|
</script>`;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface DecidePageItem {
|
|
2
|
+
kind: 'approval' | 'question';
|
|
3
|
+
agent: string;
|
|
4
|
+
tool: string;
|
|
5
|
+
args: Record<string, unknown>;
|
|
6
|
+
reason: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
tenant: string;
|
|
9
|
+
createdAt?: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
/** A link that's malformed, tampered with, or past its expiry. */
|
|
13
|
+
export declare function renderDecideInvalidPage(): string;
|
|
14
|
+
/** A valid link whose decision has already been made. */
|
|
15
|
+
export declare function renderDecideGonePage(): string;
|
|
16
|
+
export declare function renderDecidePage(item: DecidePageItem): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The page a decision link opens (GET /decide/<token>, see
|
|
2
|
+
// core/approval-links.ts): what's waiting — the tool and its arguments,
|
|
3
|
+
// or the question — and buttons to approve, deny or answer. Anyone with
|
|
4
|
+
// the link can decide; the signed token is the only credential, so this
|
|
5
|
+
// page shows nothing beyond the one decision it names.
|
|
6
|
+
//
|
|
7
|
+
// Rendered on the server; the small script only posts the choice back to
|
|
8
|
+
// the same URL (POST /decide/<token>) and shows the outcome. Written
|
|
9
|
+
// without backticks or backslashes inside the script, same convention as
|
|
10
|
+
// the other embedded pages.
|
|
11
|
+
import { devUiCss } from './dev-ui-styles.js';
|
|
12
|
+
function esc(s) {
|
|
13
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
14
|
+
}
|
|
15
|
+
const PAGE_CSS = `
|
|
16
|
+
body { display: grid; place-items: start center; padding: 48px 16px; background: var(--surface); }
|
|
17
|
+
main { width: 100%; max-width: 560px; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 24px; display: grid; gap: 16px; }
|
|
18
|
+
.brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--ink-muted); font-size: 13px; }
|
|
19
|
+
.mark { width: 18px; height: 18px; border-radius: 5px; background: var(--accent); }
|
|
20
|
+
h1 { font-size: 20px; margin: 0; }
|
|
21
|
+
dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; font-size: 13px; }
|
|
22
|
+
dt { color: var(--ink-faint); }
|
|
23
|
+
dd { margin: 0; overflow-wrap: anywhere; }
|
|
24
|
+
pre, textarea { font-family: var(--font-mono); font-size: 12.5px; background: var(--code-bg); border-radius: 8px; padding: 12px; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
25
|
+
textarea { width: 100%; min-height: 120px; border: 1px solid var(--line); }
|
|
26
|
+
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
27
|
+
.actions button { padding: 8px 16px; }
|
|
28
|
+
.deny { color: var(--bad); }
|
|
29
|
+
.note { font-size: 12px; color: var(--ink-faint); margin: 0; }
|
|
30
|
+
.result { padding: 12px 14px; border-radius: 8px; background: var(--good-soft); color: var(--good); }
|
|
31
|
+
.result.bad { background: var(--bad-soft); color: var(--bad); }
|
|
32
|
+
.reply { white-space: pre-wrap; background: var(--surface); border-radius: 8px; padding: 12px 14px; }
|
|
33
|
+
details summary { cursor: pointer; font-size: 13px; color: var(--ink-muted); }
|
|
34
|
+
`;
|
|
35
|
+
function shell(title, body, script = '') {
|
|
36
|
+
return `<!doctype html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head>
|
|
39
|
+
<meta charset="utf-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
41
|
+
<meta name="robots" content="noindex">
|
|
42
|
+
<title>${esc(title)}</title>
|
|
43
|
+
<style>${devUiCss}${PAGE_CSS}</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<main>
|
|
47
|
+
<div class="brand"><span class="mark" aria-hidden="true"></span>LoopEngine</div>
|
|
48
|
+
${body}
|
|
49
|
+
</main>
|
|
50
|
+
${script}
|
|
51
|
+
</body>
|
|
52
|
+
</html>`;
|
|
53
|
+
}
|
|
54
|
+
/** A link that's malformed, tampered with, or past its expiry. */
|
|
55
|
+
export function renderDecideInvalidPage() {
|
|
56
|
+
return shell('Link not valid', '<h1>This link isn't valid</h1><p class="muted">It may have expired or been copied incompletely. Ask for a new one, or decide from the agent's Permissions tab.</p>');
|
|
57
|
+
}
|
|
58
|
+
/** A valid link whose decision has already been made. */
|
|
59
|
+
export function renderDecideGonePage() {
|
|
60
|
+
return shell('Already decided', '<h1>Already decided</h1><p class="muted">Someone has already made this decision, so there's nothing left to do here.</p>');
|
|
61
|
+
}
|
|
62
|
+
const SCRIPT = `<script>
|
|
63
|
+
(function () {
|
|
64
|
+
var main = document.querySelector('main');
|
|
65
|
+
var result = document.getElementById('result');
|
|
66
|
+
function send(body, button) {
|
|
67
|
+
var buttons = main.querySelectorAll('button');
|
|
68
|
+
for (var i = 0; i < buttons.length; i++) buttons[i].disabled = true;
|
|
69
|
+
result.hidden = false;
|
|
70
|
+
result.className = 'result';
|
|
71
|
+
result.textContent = 'Sending' + String.fromCharCode(8230);
|
|
72
|
+
fetch(location.pathname, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
73
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
74
|
+
.then(function (r) {
|
|
75
|
+
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
76
|
+
var form = document.getElementById('decision');
|
|
77
|
+
if (form) form.hidden = true;
|
|
78
|
+
result.textContent = r.body.message || (body.decision === 'deny' ? 'Denied.' : body.answer !== undefined ? 'Answer sent.' : 'Approved.');
|
|
79
|
+
if (r.body.text) {
|
|
80
|
+
var reply = document.createElement('div');
|
|
81
|
+
reply.className = 'reply';
|
|
82
|
+
reply.textContent = r.body.text;
|
|
83
|
+
result.after(reply);
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.catch(function (err) {
|
|
87
|
+
result.className = 'result bad';
|
|
88
|
+
result.textContent = err.message;
|
|
89
|
+
for (var i = 0; i < buttons.length; i++) buttons[i].disabled = false;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
main.addEventListener('click', function (ev) {
|
|
93
|
+
var btn = ev.target.closest('button[data-send]');
|
|
94
|
+
if (!btn) return;
|
|
95
|
+
var payload = JSON.parse(btn.getAttribute('data-send'));
|
|
96
|
+
if (payload.editedFrom) {
|
|
97
|
+
try { payload = { decision: 'approve', editedArgs: JSON.parse(document.getElementById(payload.editedFrom).value) }; }
|
|
98
|
+
catch (e) { result.hidden = false; result.className = 'result bad'; result.textContent = 'The arguments aren' + String.fromCharCode(39) + 't valid JSON.'; return; }
|
|
99
|
+
}
|
|
100
|
+
if (payload.answerFrom) {
|
|
101
|
+
var text = document.getElementById(payload.answerFrom).value.trim();
|
|
102
|
+
if (!text) return;
|
|
103
|
+
payload = { answer: text };
|
|
104
|
+
}
|
|
105
|
+
send(payload, btn);
|
|
106
|
+
});
|
|
107
|
+
})();
|
|
108
|
+
</script>`;
|
|
109
|
+
export function renderDecidePage(item) {
|
|
110
|
+
const expires = new Date(item.expiresAt).toUTCString();
|
|
111
|
+
const facts = `<dl><dt>Agent</dt><dd>${esc(item.agent)}</dd>` +
|
|
112
|
+
`<dt>Conversation</dt><dd><code>${esc(item.sessionId)}</code>${item.tenant && item.tenant !== 'default' ? ` (tenant ${esc(item.tenant)})` : ''}</dd>` +
|
|
113
|
+
(item.createdAt ? `<dt>Asked</dt><dd>${esc(new Date(item.createdAt).toUTCString())}</dd>` : '') +
|
|
114
|
+
`</dl>`;
|
|
115
|
+
const note = `<p class="note">Anyone with this link can make this decision. It works once and expires ${esc(expires)}.</p>`;
|
|
116
|
+
const result = '<div id="result" class="result" hidden role="status"></div>';
|
|
117
|
+
if (item.kind === 'question') {
|
|
118
|
+
const question = typeof item.args.question === 'string' ? item.args.question : item.reason;
|
|
119
|
+
const options = Array.isArray(item.args.options) ? item.args.options.map(String) : [];
|
|
120
|
+
const optionButtons = options.map((o) => `<button type="button" data-send="${esc(JSON.stringify({ answer: o }))}">${esc(o)}</button>`).join('');
|
|
121
|
+
return shell(`Question from ${item.agent}`, `<h1>${esc(item.agent)} has a question</h1>${facts}<pre>${esc(question)}</pre>` +
|
|
122
|
+
`<div id="decision" style="display:grid;gap:10px">` +
|
|
123
|
+
(optionButtons ? `<div class="actions">${optionButtons}</div>` : '') +
|
|
124
|
+
`<label style="display:grid;gap:6px;font-size:13px">${options.length ? 'Or answer in your own words' : 'Your answer'}<textarea id="answerText" style="min-height:80px;font-family:var(--font-sans)"></textarea></label>` +
|
|
125
|
+
`<div class="actions"><button type="button" class="primary" data-send="${esc(JSON.stringify({ answerFrom: 'answerText' }))}">Send answer</button></div></div>` +
|
|
126
|
+
result + note, SCRIPT);
|
|
127
|
+
}
|
|
128
|
+
return shell(`Approve ${item.tool}?`, `<h1>Approve <code>${esc(item.tool)}</code>?</h1>${facts}` +
|
|
129
|
+
`<pre>${esc(JSON.stringify(item.args, null, 2))}</pre>` +
|
|
130
|
+
`<p class="muted" style="margin:0">${esc(item.reason)}</p>` +
|
|
131
|
+
`<div id="decision" style="display:grid;gap:12px">` +
|
|
132
|
+
`<div class="actions"><button type="button" class="primary" data-send="${esc(JSON.stringify({ decision: 'approve' }))}">Approve</button>` +
|
|
133
|
+
`<button type="button" class="deny" data-send="${esc(JSON.stringify({ decision: 'deny' }))}">Deny</button></div>` +
|
|
134
|
+
`<details><summary>Change the arguments first</summary><div style="display:grid;gap:8px;margin-top:8px">` +
|
|
135
|
+
`<textarea id="editedArgs">${esc(JSON.stringify(item.args, null, 2))}</textarea>` +
|
|
136
|
+
`<div class="actions"><button type="button" data-send="${esc(JSON.stringify({ editedFrom: 'editedArgs' }))}">Approve with these arguments</button></div></div></details>` +
|
|
137
|
+
`</div>` + result + note, SCRIPT);
|
|
138
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AgentConfig } from '../core/agent-config.js';
|
|
2
|
+
import type { CheckpointStore } from '../core/durable-approvals.js';
|
|
3
|
+
import { type DeliveryResult } from '../core/http-notify-triggers/webhook.js';
|
|
4
|
+
import { type NotifierFileChannel } from '../core/notifier-file.js';
|
|
5
|
+
/** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
|
|
6
|
+
* _SECRET), which applies to the plain /messages route when an agent has
|
|
7
|
+
* no notifier of its own. */
|
|
8
|
+
export interface DefaultWebhook {
|
|
9
|
+
webhookUrl: string;
|
|
10
|
+
webhookSecret: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function describeAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): {
|
|
13
|
+
source: string;
|
|
14
|
+
defaultWebhookUrl: string | null;
|
|
15
|
+
publicUrl: string | null;
|
|
16
|
+
secretVars: Record<NotifierFileChannel, string>;
|
|
17
|
+
secretsSet: {
|
|
18
|
+
[k: string]: boolean;
|
|
19
|
+
};
|
|
20
|
+
deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
|
|
21
|
+
channel?: import("../core/agent-config.js").HttpNotifierConfig["channel"];
|
|
22
|
+
events?: string[];
|
|
23
|
+
file?: import("../core/notifier-file.js").NotifierFile;
|
|
24
|
+
missingSecret?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
/** Writes agents/<name>/notifier.yml from the console form and, when the
|
|
28
|
+
* form includes one, the channel's secret into the agent's own env.
|
|
29
|
+
* Throws NotifierFileError for an invalid form. */
|
|
30
|
+
export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
|
|
31
|
+
/** Removes notifier.yml; the stored secret is left alone, so turning the
|
|
32
|
+
* notifier back on doesn't need it re-entered. */
|
|
33
|
+
export declare function removeAgentNotifier(config: AgentConfig): void;
|
|
34
|
+
/** Sends a test through whichever notifier applies to this agent —
|
|
35
|
+
* webhook or Slack; reports what the receiver answered. */
|
|
36
|
+
export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): Promise<DeliveryResult & {
|
|
37
|
+
channel?: string;
|
|
38
|
+
}>;
|
|
39
|
+
/** Every decision still waiting for this agent, oldest first, each with
|
|
40
|
+
* where to send the answer. */
|
|
41
|
+
export declare function listAgentPending(store: CheckpointStore, agentName: string): Promise<({
|
|
42
|
+
decidePath: string;
|
|
43
|
+
answerPath: string;
|
|
44
|
+
pendingId: string;
|
|
45
|
+
kind: "approval" | "question";
|
|
46
|
+
tool: string;
|
|
47
|
+
args: Record<string, unknown>;
|
|
48
|
+
reason: string;
|
|
49
|
+
sessionId: string;
|
|
50
|
+
tenant: string;
|
|
51
|
+
createdAt: string | null;
|
|
52
|
+
} | {
|
|
53
|
+
decidePath: string;
|
|
54
|
+
resolvePath: string;
|
|
55
|
+
pendingId: string;
|
|
56
|
+
kind: "approval" | "question";
|
|
57
|
+
tool: string;
|
|
58
|
+
args: Record<string, unknown>;
|
|
59
|
+
reason: string;
|
|
60
|
+
sessionId: string;
|
|
61
|
+
tenant: string;
|
|
62
|
+
createdAt: string | null;
|
|
63
|
+
})[]>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { agentDirFor, createAgentEnv } from '../core/agent-env.js';
|
|
2
|
+
import { decidePath } from '../core/approval-links.js';
|
|
3
|
+
import { describeNotifier } from '../core/http-notifier.js';
|
|
4
|
+
import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
|
|
5
|
+
import { sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
|
|
6
|
+
import { listDeliveries } from '../core/notifier-deliveries.js';
|
|
7
|
+
import { NOTIFIER_SECRET_VARS, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
|
|
8
|
+
import { setAgentEnvVar } from './env-admin.js';
|
|
9
|
+
export function describeAgentNotifier(config, defaultWebhook) {
|
|
10
|
+
const described = describeNotifier(config);
|
|
11
|
+
const env = createAgentEnv(config.name, agentDirFor(config));
|
|
12
|
+
const secretsSet = Object.fromEntries(Object.keys(NOTIFIER_SECRET_VARS).map((channel) => [channel, env.get(NOTIFIER_SECRET_VARS[channel]) !== undefined]));
|
|
13
|
+
const source = described.source === 'none' && defaultWebhook ? 'default' : described.source;
|
|
14
|
+
return {
|
|
15
|
+
...described,
|
|
16
|
+
source,
|
|
17
|
+
defaultWebhookUrl: defaultWebhook?.webhookUrl ?? null,
|
|
18
|
+
publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
|
|
19
|
+
secretVars: NOTIFIER_SECRET_VARS,
|
|
20
|
+
secretsSet,
|
|
21
|
+
deliveries: listDeliveries(config.name),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Writes agents/<name>/notifier.yml from the console form and, when the
|
|
25
|
+
* form includes one, the channel's secret into the agent's own env.
|
|
26
|
+
* Throws NotifierFileError for an invalid form. */
|
|
27
|
+
export async function saveAgentNotifier(config, body) {
|
|
28
|
+
const file = writeNotifierFile(agentDirFor(config), body);
|
|
29
|
+
if (typeof body.secret === 'string' && body.secret.trim()) {
|
|
30
|
+
await setAgentEnvVar(config.name, NOTIFIER_SECRET_VARS[file.channel], body.secret.trim());
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Removes notifier.yml; the stored secret is left alone, so turning the
|
|
34
|
+
* notifier back on doesn't need it re-entered. */
|
|
35
|
+
export function removeAgentNotifier(config) {
|
|
36
|
+
removeNotifierFile(agentDirFor(config));
|
|
37
|
+
}
|
|
38
|
+
/** Sends a test through whichever notifier applies to this agent —
|
|
39
|
+
* webhook or Slack; reports what the receiver answered. */
|
|
40
|
+
export async function testAgentNotifier(config, defaultWebhook) {
|
|
41
|
+
let target = config.httpNotifier;
|
|
42
|
+
if (!target) {
|
|
43
|
+
const dir = agentDirFor(config);
|
|
44
|
+
const file = readNotifierFile(dir);
|
|
45
|
+
if (file) {
|
|
46
|
+
const { config: fromFile, missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
47
|
+
if (!fromFile)
|
|
48
|
+
return { ok: false, channel: file.channel, error: `${missingSecret} is not set for this agent` };
|
|
49
|
+
target = fromFile;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!target && defaultWebhook) {
|
|
53
|
+
return { channel: 'webhook', ...(await sendTestWebhook(defaultWebhook.webhookUrl, defaultWebhook.webhookSecret, config.name)) };
|
|
54
|
+
}
|
|
55
|
+
if (!target)
|
|
56
|
+
return { ok: false, error: 'This agent has no notifier set up' };
|
|
57
|
+
if (target.channel === 'webhook') {
|
|
58
|
+
return { channel: 'webhook', ...(await sendTestWebhook(target.config.webhookUrl, target.config.webhookSecret, config.name)) };
|
|
59
|
+
}
|
|
60
|
+
if (target.channel === 'slack') {
|
|
61
|
+
return { channel: 'slack', ...(await sendTestSlackMessage(target.config.botToken, target.config.channelId, `Test from LoopEngine: approvals for ${config.name} will be posted here.`)) };
|
|
62
|
+
}
|
|
63
|
+
return { ok: false, channel: target.channel, error: `Send test isn't supported for ${target.channel} yet` };
|
|
64
|
+
}
|
|
65
|
+
/** Every decision still waiting for this agent, oldest first, each with
|
|
66
|
+
* where to send the answer. */
|
|
67
|
+
export async function listAgentPending(store, agentName) {
|
|
68
|
+
const open = await store.listOpen();
|
|
69
|
+
const pending = [];
|
|
70
|
+
for (const checkpoint of open) {
|
|
71
|
+
if (checkpoint.agent !== agentName)
|
|
72
|
+
continue;
|
|
73
|
+
for (const [pendingId, item] of Object.entries(checkpoint.outstanding)) {
|
|
74
|
+
const kind = item.kind ?? 'approval';
|
|
75
|
+
pending.push({
|
|
76
|
+
pendingId,
|
|
77
|
+
kind,
|
|
78
|
+
tool: item.tool,
|
|
79
|
+
args: item.args,
|
|
80
|
+
reason: item.reason,
|
|
81
|
+
sessionId: checkpoint.sessionId,
|
|
82
|
+
tenant: checkpoint.tenant,
|
|
83
|
+
createdAt: checkpoint.createdAt ?? null,
|
|
84
|
+
...(kind === 'question' ? { answerPath: `/pending-questions/${pendingId}/answer` } : { resolvePath: `/pending-approvals/${pendingId}/resolve` }),
|
|
85
|
+
// A signed link anyone can open to decide (core/approval-links.ts).
|
|
86
|
+
decidePath: decidePath(pendingId, kind),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return pending;
|
|
91
|
+
}
|