@floless/app 0.6.3 → 0.7.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/dist/web/app.css CHANGED
@@ -2065,6 +2065,42 @@ body {
2065
2065
  }
2066
2066
  .report-overlay .overlay-stop:disabled { opacity: 0.6; cursor: default; }
2067
2067
 
2068
+ /* Credential-failure reconnect card — shown in .report-overlay when a run hits a
2069
+ rejected OAuth token. Amber-topped (recoverable, needs attention); the Reconnect
2070
+ action keeps primary-blue (the one critical next step), Open Integrations is ghost. */
2071
+ .cred-fail-card {
2072
+ background: var(--surface); border: 1px solid var(--border-strong); border-top: 3px solid var(--warn);
2073
+ border-radius: 8px; padding: 22px 24px; max-width: 380px; width: 100%;
2074
+ display: flex; flex-direction: column; gap: 14px; text-align: left;
2075
+ }
2076
+ .cred-fail-status {
2077
+ display: flex; align-items: center; gap: 8px;
2078
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim);
2079
+ }
2080
+ .cred-fail-icon { color: var(--warn); font-size: 16px; line-height: 1; }
2081
+ .cred-fail-headline { font-size: 15px; font-weight: 600; color: var(--text); }
2082
+ .cred-fail-body { font-size: 13px; color: var(--text-muted); line-height: 1.55; }
2083
+ .cred-fail-err { font-size: 12px; color: var(--err); }
2084
+ .cred-fail-ok { font-size: 13px; color: var(--ok); font-weight: 600; }
2085
+ .cred-fail-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
2086
+ .cred-fail-actions button { font-family: var(--ui); font-size: 13px; line-height: 1.2; border-radius: 4px; cursor: pointer; }
2087
+ .cred-fail-actions .cred-reconnect {
2088
+ padding: 7px 16px; background: var(--accent); color: white; border: 1px solid var(--accent); font-weight: 600;
2089
+ }
2090
+ .cred-fail-actions .cred-reconnect:hover:not(:disabled) {
2091
+ background: var(--accent-bright); box-shadow: 0 0 14px var(--accent-glow);
2092
+ }
2093
+ .cred-fail-actions .cred-reconnect:disabled { opacity: 0.7; cursor: default; }
2094
+ .cred-fail-actions .cred-open-integrations {
2095
+ padding: 7px 12px; background: transparent; border: 1px solid var(--border-strong); color: var(--text-muted);
2096
+ }
2097
+ .cred-fail-actions .cred-open-integrations:hover { color: var(--text); border-color: var(--accent-dim); }
2098
+ .cred-reconnect-spinner {
2099
+ display: inline-block; width: 12px; height: 12px; vertical-align: -2px; margin-right: 6px;
2100
+ border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: #fff; border-radius: 50%;
2101
+ animation: spin 0.8s linear infinite;
2102
+ }
2103
+
2068
2104
  /* Report + input nodes on the canvas carry a first-class action button
2069
2105
  ("View report ▸" / "Set inputs ▸"). Double-click still works as a shortcut. */
2070
2106
  .agent-card.report-node,
package/dist/web/aware.js CHANGED
@@ -106,7 +106,11 @@
106
106
  ...opts,
107
107
  });
108
108
  const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
109
- if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
109
+ if (!res.ok || body.ok === false) {
110
+ const err = new Error(body.error || `HTTP ${res.status}`);
111
+ err.body = body; // preserve the in-band payload (e.g. credentialIssue) for callers
112
+ throw err;
113
+ }
110
114
  return body;
111
115
  }
112
116
 
@@ -651,7 +655,12 @@
651
655
  function reportNodeId() {
652
656
  const app = currentId && apps.get(currentId);
653
657
  if (!app || !app.nodes.length) return null;
654
- // Qualify the app only if some node actually PRODUCES report HTML — its exec
658
+ // An html-report agent node renders report HTML — its `render` command returns
659
+ // an `html` field, which is exactly what extractReportHtml keys on. So it's a
660
+ // report producer regardless of exec code; prefer it directly as the viewer node.
661
+ const htmlReportNode = app.nodes.find((n) => n.agent === 'html-report');
662
+ if (htmlReportNode) return htmlReportNode.id;
663
+ // Otherwise, qualify the app only if some node actually PRODUCES report HTML — its exec
655
664
  // code returns an `html` field (the `data.result.html` extractReportHtml keys
656
665
  // on), or it forwards `{{ <node>.result.html }}` through its args (a
657
666
  // pass-through viewer). "any node has exec code" is NOT enough: a plain
@@ -954,6 +963,9 @@
954
963
  : await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate: false, inputs }) });
955
964
  // Reflect the run in the Execution tab too.
956
965
  if (Array.isArray(res.events)) { liveTrace.length = 0; res.events.forEach(pushTrace); state.hasRun = true; if (state.currentTab === 'execution') renderInspect(); }
966
+ // A run can "succeed" yet carry a rejected token (the report would be an error
967
+ // body, not real data) — prompt reconnect instead of rendering the noise.
968
+ if (res.credentialIssue) { surfaceCredentialIssue(res.credentialIssue); return; }
957
969
  const html = res.report && res.report.html;
958
970
  if (!html) {
959
971
  $reportOverlay.innerHTML = `<div>Run completed but <code>${escapeHtml(nodeId)}</code> returned no <code>html</code>. Check the Execution tab.</div>`;
@@ -972,17 +984,115 @@
972
984
  $reportOverlay.innerHTML = `<div>Run cancelled. A node already running on the host may still finish there; click <strong>▶ Run workflow</strong> to run again.</div>`;
973
985
  showToast('Run cancelled', 'info');
974
986
  } else {
975
- // Expected, recoverable failure (host not attached, stale lock, …): show it
976
- // in the viewer overlay + a toast. No console.error — the server returns the
977
- // failure in-band, so a failed run isn't a console-worthy fault.
978
- $reportOverlay.innerHTML = `<div>Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.</div>`;
979
- showToast('Run failed: ' + msg, 'warn');
987
+ // Expected, recoverable failure. If it was a rejected token, prompt the
988
+ // user to reconnect that integration (not the misleading host hint).
989
+ const cred = e.body && e.body.credentialIssue;
990
+ if (cred) {
991
+ surfaceCredentialIssue(cred);
992
+ } else {
993
+ // Other recoverable failures (host not attached, stale lock, …): show it
994
+ // in the viewer overlay + a toast. No console.error — the server returns the
995
+ // failure in-band, so a failed run isn't a console-worthy fault.
996
+ $reportOverlay.innerHTML = `<div>Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.</div>`;
997
+ showToast('Run failed: ' + msg, 'warn');
998
+ }
980
999
  }
981
1000
  } finally {
982
1001
  reportRunning = false;
983
1002
  }
984
1003
  }
985
1004
 
1005
+ // ── Credential-reconnect card ──────────────────────────────────────────────
1006
+ // When a real run fails (or "succeeds" with a 401 body) because an integration's
1007
+ // OAuth token was rejected, the server returns `credentialIssue: {id,label}`. We
1008
+ // replace the misleading "check the host" message with a focused prompt to
1009
+ // reconnect that integration — generic across Trimble, M365, any OAuth provider.
1010
+ // Built with DOM nodes + textContent (no innerHTML) so the integration label can
1011
+ // never inject markup.
1012
+ let credReconnect = null; // { id, onSuccess, onFail } — latched while a card reconnect runs
1013
+
1014
+ function mkEl(tag, cls, text) {
1015
+ const n = document.createElement(tag);
1016
+ if (cls) n.className = cls;
1017
+ if (text != null) n.textContent = text;
1018
+ return n;
1019
+ }
1020
+
1021
+ const CRED_BODY_IDLE = 'Sign in again and then run the workflow — it takes about 30 seconds.';
1022
+
1023
+ function showCredentialReconnect(cred) {
1024
+ $reportFrame.srcdoc = '';
1025
+ $reportOverlay.hidden = false;
1026
+ $reportOverlay.replaceChildren();
1027
+
1028
+ const card = mkEl('div', 'cred-fail-card');
1029
+ card.setAttribute('role', 'status');
1030
+
1031
+ const status = mkEl('div', 'cred-fail-status');
1032
+ const icon = mkEl('span', 'cred-fail-icon', iconFor(cred.id));
1033
+ icon.setAttribute('aria-hidden', 'true');
1034
+ status.append(icon, document.createTextNode('SESSION EXPIRED'));
1035
+
1036
+ const headline = mkEl('div', 'cred-fail-headline', `Your ${cred.label} session expired`);
1037
+ const body = mkEl('div', 'cred-fail-body', CRED_BODY_IDLE);
1038
+
1039
+ const actions = mkEl('div', 'cred-fail-actions');
1040
+ const reconnect = mkEl('button', 'primary cred-reconnect', 'Reconnect');
1041
+ reconnect.type = 'button';
1042
+ const openInt = mkEl('button', 'cred-open-integrations', 'Open Integrations');
1043
+ openInt.type = 'button';
1044
+ actions.append(reconnect, openInt);
1045
+
1046
+ card.append(status, headline, body, actions);
1047
+ $reportOverlay.append(card);
1048
+ showModal($reportModal);
1049
+
1050
+ openInt.onclick = () => { hideModal($reportModal); openIntegrations(); };
1051
+ reconnect.onclick = () => credReconnectStart(cred, card, reconnect, body);
1052
+ reconnect.focus();
1053
+ }
1054
+
1055
+ function credReconnectStart(cred, card, btn, body) {
1056
+ const reset = (errMsg) => {
1057
+ credReconnect = null;
1058
+ btn.disabled = false; btn.textContent = 'Reconnect';
1059
+ body.textContent = CRED_BODY_IDLE;
1060
+ let e = card.querySelector('.cred-fail-err');
1061
+ if (!e) { e = mkEl('div', 'cred-fail-err'); body.after(e); }
1062
+ e.textContent = errMsg;
1063
+ };
1064
+ const oldErr = card.querySelector('.cred-fail-err'); if (oldErr) oldErr.remove();
1065
+ btn.disabled = true;
1066
+ btn.textContent = '';
1067
+ const sp = mkEl('span', 'cred-reconnect-spinner'); sp.setAttribute('aria-hidden', 'true');
1068
+ btn.append(sp, document.createTextNode('Signing in…'));
1069
+ body.textContent = 'Opening your browser to sign in — complete it there and this will update.';
1070
+
1071
+ credReconnect = {
1072
+ id: cred.id,
1073
+ onSuccess: () => {
1074
+ credReconnect = null;
1075
+ const actions = card.querySelector('.cred-fail-actions'); if (actions) actions.remove();
1076
+ body.replaceChildren(mkEl('span', 'cred-fail-ok', 'Connected — click ▶ Run workflow to continue.'));
1077
+ setTimeout(() => {
1078
+ if ($reportOverlay.querySelector('.cred-fail-card')) { $reportOverlay.hidden = true; $reportOverlay.replaceChildren(); }
1079
+ }, 2000);
1080
+ },
1081
+ onFail: () => reset('Sign-in failed — try again, or use the device code flow in Integrations.'),
1082
+ };
1083
+ api(`/api/connect/${encodeURIComponent(cred.id)}`, { method: 'POST', body: '{}' })
1084
+ .then((res) => { if (res && res.started === false) reset('Couldn’t start sign-in — open Integrations to retry.'); })
1085
+ .catch(() => reset('Sign-in failed — try again, or use the device code flow in Integrations.'));
1086
+ }
1087
+
1088
+ // Shared by both run paths: when the server flags a credential issue, show the
1089
+ // reconnect card + a quiet toast/narration instead of the generic failure copy.
1090
+ function surfaceCredentialIssue(cred) {
1091
+ showCredentialReconnect(cred);
1092
+ showToast(`Session expired — reconnect ${cred.label} to continue.`, 'warn');
1093
+ appendNarration(`Run stopped — your <strong>${escapeHtml(cred.label)}</strong> session expired. Reconnect and run again.`);
1094
+ }
1095
+
986
1096
  function showModal(el) { el.classList.add('show'); }
987
1097
  function hideModal(el) { el.classList.remove('show'); }
988
1098
 
@@ -1617,6 +1727,8 @@
1617
1727
  const res = await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate, inputs: currentInputs() }) });
1618
1728
  // SSE usually fills liveTrace first; backfill from the response if it didn't.
1619
1729
  if (liveTrace.length === 0 && Array.isArray(res.events)) res.events.forEach(pushTrace);
1730
+ // A "succeeded" run carrying a rejected token → prompt reconnect, not a result.
1731
+ if (res.credentialIssue) { surfaceCredentialIssue(res.credentialIssue); return; }
1620
1732
  switchToExecution();
1621
1733
  const steps = liveTrace.filter((r) => r.kind === 'node-start').length;
1622
1734
  appendNarration(simulate
@@ -1629,12 +1741,18 @@
1629
1741
  showToast('Run cancelled', 'info');
1630
1742
  appendNarration('Run cancelled. Click <strong>▶ Run workflow</strong> to run again.');
1631
1743
  } else {
1632
- // A failed run is an expected, recoverable outcome (the server returns it
1633
- // in-band) — surface it as a toast + narration, not a console error.
1634
- showToast((simulate ? 'Simulate failed' : 'Run failed') + ': ' + msg, 'warn');
1635
- appendNarration(simulate
1636
- ? `Simulate couldn't validate <code>${escapeHtml(currentId)}</code> — it stubs each node from its output-schema, but exec nodes have none, so the cross-node templates render undefined. Use <strong>▶ Run workflow</strong> for a live run against the host.`
1637
- : `Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.`);
1744
+ const cred = e.body && e.body.credentialIssue;
1745
+ if (cred) {
1746
+ // A rejected token prompt reconnect for that integration, not the host hint.
1747
+ surfaceCredentialIssue(cred);
1748
+ } else {
1749
+ // A failed run is an expected, recoverable outcome (the server returns it
1750
+ // in-band) — surface it as a toast + narration, not a console error.
1751
+ showToast((simulate ? 'Simulate failed' : 'Run failed') + ': ' + msg, 'warn');
1752
+ appendNarration(simulate
1753
+ ? `Simulate couldn't validate <code>${escapeHtml(currentId)}</code> — it stubs each node from its output-schema, but exec nodes have none, so the cross-node templates render undefined. Use <strong>▶ Run workflow</strong> for a live run against the host.`
1754
+ : `Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.`);
1755
+ }
1638
1756
  }
1639
1757
  } finally {
1640
1758
  state.running = false;
@@ -2210,6 +2328,12 @@
2210
2328
  } else if (m.type === 'trigger-session-changed') {
2211
2329
  applyTriggerSnapshot(m.id, m.snapshot);
2212
2330
  } else if (m.type === 'connect-result') {
2331
+ // A run's credential-reconnect card may be waiting on this integration —
2332
+ // drive its success/fail state from the same SSE result.
2333
+ if (credReconnect && credReconnect.id === m.id) {
2334
+ if (m.status === 'connected') credReconnect.onSuccess();
2335
+ else credReconnect.onFail(m.error);
2336
+ }
2213
2337
  // The device-code sign-in resolved. On success, refresh so the card flips
2214
2338
  // to Connected; otherwise surface the outcome in the integration's slot.
2215
2339
  connecting.delete(m.id);
@@ -2585,7 +2709,7 @@
2585
2709
  <div class="int-icon">${escapeHtml(VENDOR_ICON[i.vendor] || (i.name[0] || '?'))}</div>
2586
2710
  <div class="int-info">
2587
2711
  <div class="int-name">${escapeHtml(i.name)}</div>
2588
- <div class="int-kind">${escapeHtml(i.vendor || 'integration')} · agent <code>${escapeHtml(i.agent)}</code></div>
2712
+ <div class="int-kind">${i.agent === 'first-party' ? 'Built-in integration' : `${escapeHtml(i.vendor || 'integration')} · agent <code>${escapeHtml(i.agent)}</code>`}</div>
2589
2713
  <div class="int-scopes">${escapeHtml((i.network || []).join(' · ') || '—')}</div>
2590
2714
  <div class="int-meta">${escapeHtml(meta)}</div>
2591
2715
  <div class="int-row">
@@ -2636,6 +2760,10 @@
2636
2760
  if (slot) slot.innerHTML = 'Starting sign-in…';
2637
2761
  try {
2638
2762
  const res = await api(`/api/connect/${encodeURIComponent(id)}`, { method: 'POST', body });
2763
+ // A managed-preset pre-flight (e.g. port 80 already in use) refuses to start
2764
+ // and broadcasts the reason over SSE (connect-result). Clear the guard so the
2765
+ // user can retry after fixing it, and don't claim the browser opened.
2766
+ if (res && res.started === false) { connecting.delete(id); return; }
2639
2767
  if (slot) {
2640
2768
  if (res.flow === 'device-code' && res.prompt) {
2641
2769
  // Fallback flow: show the device code to enter at the verification URL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.6.3",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {