@floless/app 0.7.0 → 0.8.0
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/floless-server.cjs +179 -9
- package/dist/skills/floless-app-report-issue/SKILL.md +127 -0
- package/dist/web/app.css +39 -0
- package/dist/web/app.js +1 -0
- package/dist/web/aware.js +313 -12
- package/dist/web/index.html +39 -0
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -51392,6 +51392,49 @@ function parseRunHandle(stdout) {
|
|
|
51392
51392
|
function traceLogPath(id, instance, runId, logsRoot = LOGS_DIR) {
|
|
51393
51393
|
return (0, import_node_path3.join)(logsRoot, id, instance, `${runId}.jsonl`);
|
|
51394
51394
|
}
|
|
51395
|
+
function latestTracePath(id, logsRoot = LOGS_DIR) {
|
|
51396
|
+
const appDir2 = (0, import_node_path3.join)(logsRoot, id);
|
|
51397
|
+
if (!(0, import_node_fs4.existsSync)(appDir2)) return null;
|
|
51398
|
+
let best = null;
|
|
51399
|
+
let instances;
|
|
51400
|
+
try {
|
|
51401
|
+
instances = (0, import_node_fs4.readdirSync)(appDir2);
|
|
51402
|
+
} catch {
|
|
51403
|
+
return null;
|
|
51404
|
+
}
|
|
51405
|
+
for (const instance of instances) {
|
|
51406
|
+
const instDir = (0, import_node_path3.join)(appDir2, instance);
|
|
51407
|
+
let entries;
|
|
51408
|
+
try {
|
|
51409
|
+
if (!(0, import_node_fs4.statSync)(instDir).isDirectory()) continue;
|
|
51410
|
+
entries = (0, import_node_fs4.readdirSync)(instDir);
|
|
51411
|
+
} catch {
|
|
51412
|
+
continue;
|
|
51413
|
+
}
|
|
51414
|
+
for (const f of entries) {
|
|
51415
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
51416
|
+
const p = (0, import_node_path3.join)(instDir, f);
|
|
51417
|
+
try {
|
|
51418
|
+
const m = (0, import_node_fs4.statSync)(p).mtimeMs;
|
|
51419
|
+
if (!best || m > best.mtimeMs) best = { path: p, mtimeMs: m };
|
|
51420
|
+
} catch {
|
|
51421
|
+
}
|
|
51422
|
+
}
|
|
51423
|
+
}
|
|
51424
|
+
return best ? best.path : null;
|
|
51425
|
+
}
|
|
51426
|
+
function readLatestTrace(id, logsRoot = LOGS_DIR) {
|
|
51427
|
+
const p = latestTracePath(id, logsRoot);
|
|
51428
|
+
if (!p) return null;
|
|
51429
|
+
let text;
|
|
51430
|
+
try {
|
|
51431
|
+
text = (0, import_node_fs4.readFileSync)(p, "utf8");
|
|
51432
|
+
} catch {
|
|
51433
|
+
return null;
|
|
51434
|
+
}
|
|
51435
|
+
const runId = p.split(/[\\/]/).pop()?.replace(/\.jsonl$/, "") ?? "";
|
|
51436
|
+
return { runId, path: p, text };
|
|
51437
|
+
}
|
|
51395
51438
|
function startTrigger(id, opts = {}) {
|
|
51396
51439
|
assertId(id);
|
|
51397
51440
|
const invoker = currentInvoker();
|
|
@@ -52053,6 +52096,9 @@ function signInUrl() {
|
|
|
52053
52096
|
function updateApiBase() {
|
|
52054
52097
|
return `${env().apiBase}/updates/releases`;
|
|
52055
52098
|
}
|
|
52099
|
+
function apiBaseUrl() {
|
|
52100
|
+
return env().apiBase;
|
|
52101
|
+
}
|
|
52056
52102
|
async function accessToken() {
|
|
52057
52103
|
return ensureFreshToken();
|
|
52058
52104
|
}
|
|
@@ -52308,7 +52354,7 @@ function appVersion() {
|
|
|
52308
52354
|
return resolveVersion({
|
|
52309
52355
|
isSea: isSea2(),
|
|
52310
52356
|
sqVersionXml: readSqVersionXml(),
|
|
52311
|
-
define: true ? "0.
|
|
52357
|
+
define: true ? "0.8.0" : void 0,
|
|
52312
52358
|
pkgVersion: readPkgVersion()
|
|
52313
52359
|
});
|
|
52314
52360
|
}
|
|
@@ -52318,7 +52364,7 @@ function resolveChannel(s) {
|
|
|
52318
52364
|
return "dev";
|
|
52319
52365
|
}
|
|
52320
52366
|
function appChannel() {
|
|
52321
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52367
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.8.0" : void 0 });
|
|
52322
52368
|
}
|
|
52323
52369
|
|
|
52324
52370
|
// oauth-presets.ts
|
|
@@ -53844,6 +53890,69 @@ async function applyUpdate(check, opts) {
|
|
|
53844
53890
|
return { applied: true, message: `updating to v${check.targetVersion}\u2026 the app will relaunch` };
|
|
53845
53891
|
}
|
|
53846
53892
|
|
|
53893
|
+
// report-relay.ts
|
|
53894
|
+
var REPORT_TIMEOUT_MS = 15e3;
|
|
53895
|
+
async function reportIssue(input) {
|
|
53896
|
+
let token;
|
|
53897
|
+
try {
|
|
53898
|
+
token = await accessToken();
|
|
53899
|
+
} catch {
|
|
53900
|
+
return { ok: false, error: "offline" };
|
|
53901
|
+
}
|
|
53902
|
+
if (!token) return { ok: false, error: "signed_out" };
|
|
53903
|
+
const url = `${apiBaseUrl()}/issues`;
|
|
53904
|
+
let res;
|
|
53905
|
+
try {
|
|
53906
|
+
res = await authedFetch(url, {
|
|
53907
|
+
method: "POST",
|
|
53908
|
+
headers: { "Content-Type": "application/json" },
|
|
53909
|
+
body: JSON.stringify(input),
|
|
53910
|
+
signal: AbortSignal.timeout(REPORT_TIMEOUT_MS)
|
|
53911
|
+
});
|
|
53912
|
+
} catch {
|
|
53913
|
+
return { ok: false, error: "offline" };
|
|
53914
|
+
}
|
|
53915
|
+
if (res.status === 401) return { ok: false, error: "signed_out" };
|
|
53916
|
+
if (res.status === 429) return { ok: false, error: "rate_limited" };
|
|
53917
|
+
if (!res.ok) return { ok: false, error: "offline" };
|
|
53918
|
+
let ref = "";
|
|
53919
|
+
try {
|
|
53920
|
+
const b = await res.json();
|
|
53921
|
+
if (typeof b.ref === "string") ref = b.ref;
|
|
53922
|
+
} catch {
|
|
53923
|
+
}
|
|
53924
|
+
return { ok: true, ref };
|
|
53925
|
+
}
|
|
53926
|
+
|
|
53927
|
+
// run-summary.ts
|
|
53928
|
+
var clampMsg = (s) => {
|
|
53929
|
+
const t = s.trim();
|
|
53930
|
+
return t.length > 1e3 ? t.slice(0, 1e3) : t;
|
|
53931
|
+
};
|
|
53932
|
+
function summarizeRun(events) {
|
|
53933
|
+
const runEnd = [...events].reverse().find((e) => e.kind === "run-end");
|
|
53934
|
+
const status = runEnd && typeof runEnd.status === "string" ? runEnd.status : events.length ? "unknown" : "none";
|
|
53935
|
+
let errorNodeId = null;
|
|
53936
|
+
let errorMessage = null;
|
|
53937
|
+
for (const e of events) {
|
|
53938
|
+
if (e.kind === "node-error" && typeof e.node === "string") {
|
|
53939
|
+
errorNodeId = e.node;
|
|
53940
|
+
const err = e.error;
|
|
53941
|
+
errorMessage = typeof err === "string" && err.trim() ? clampMsg(err) : null;
|
|
53942
|
+
break;
|
|
53943
|
+
}
|
|
53944
|
+
const data = e.data;
|
|
53945
|
+
const result = data?.result ?? data;
|
|
53946
|
+
if (result && result.ok === false && typeof e.node === "string") {
|
|
53947
|
+
errorNodeId = e.node;
|
|
53948
|
+
const err = result.error;
|
|
53949
|
+
errorMessage = typeof err === "string" && err.trim() ? clampMsg(err) : null;
|
|
53950
|
+
break;
|
|
53951
|
+
}
|
|
53952
|
+
}
|
|
53953
|
+
return { status, errorNodeId, errorMessage };
|
|
53954
|
+
}
|
|
53955
|
+
|
|
53847
53956
|
// launch.mjs
|
|
53848
53957
|
var import_node_child_process5 = require("node:child_process");
|
|
53849
53958
|
var import_node_path14 = require("node:path");
|
|
@@ -54321,13 +54430,15 @@ var import_yaml5 = __toESM(require_dist6(), 1);
|
|
|
54321
54430
|
// build/ship-skills.mjs
|
|
54322
54431
|
var PRODUCT_SKILLS = [
|
|
54323
54432
|
"floless-app-bridge",
|
|
54324
|
-
//
|
|
54433
|
+
// drive the floless.app CLI / desktop bridge from the user's AI
|
|
54325
54434
|
"floless-app-onboarding",
|
|
54326
|
-
//
|
|
54435
|
+
// guided, re-runnable tour of AWARE + floless.app for new users
|
|
54436
|
+
"floless-app-report-issue",
|
|
54437
|
+
// file a diagnosed bug/idea/question to the private log via the floless.io relay
|
|
54327
54438
|
"floless-app-routines",
|
|
54328
|
-
//
|
|
54439
|
+
// author event-driven ("on trigger") routines
|
|
54329
54440
|
"floless-app-workflows"
|
|
54330
|
-
//
|
|
54441
|
+
// author/run .flo workflows
|
|
54331
54442
|
];
|
|
54332
54443
|
function selectShippedSkillNames(names) {
|
|
54333
54444
|
const present = new Set(names);
|
|
@@ -56318,7 +56429,10 @@ async function startServer() {
|
|
|
56318
56429
|
});
|
|
56319
56430
|
app.addHook("onRequest", async (req, reply) => {
|
|
56320
56431
|
if (!req.url.startsWith("/api/")) return;
|
|
56321
|
-
if (req.url.startsWith("/api/health") || req.url.startsWith("/api/license/") || req.url.startsWith("/api/bootstrap/") || req.url.startsWith("/api/autostart") ||
|
|
56432
|
+
if (req.url.startsWith("/api/health") || req.url.startsWith("/api/license/") || req.url.startsWith("/api/bootstrap/") || req.url.startsWith("/api/autostart") || // Report-an-issue is a support lifeline: a user whose subscription lapsed must still be
|
|
56433
|
+
// able to report "I got locked out". It touches no workspace data and the relay still
|
|
56434
|
+
// requires a valid SESSION token (so it's not open abuse — see report-relay.ts).
|
|
56435
|
+
req.url.startsWith("/api/report-issue") || req.url.startsWith("/api/update") || req.url.startsWith("/api/aware/update")) return;
|
|
56322
56436
|
const { state: state2, signInUrl: signInUrl2 } = await getLicenseStatus();
|
|
56323
56437
|
if (state2 !== "valid" && state2 !== "offline-grace") {
|
|
56324
56438
|
return reply.status(402).send({ ok: false, error: "subscription required", state: state2, signInUrl: signInUrl2 });
|
|
@@ -56355,6 +56469,24 @@ async function startServer() {
|
|
|
56355
56469
|
logout();
|
|
56356
56470
|
return { ok: true };
|
|
56357
56471
|
});
|
|
56472
|
+
app.post(
|
|
56473
|
+
"/api/report-issue",
|
|
56474
|
+
async (req, reply) => {
|
|
56475
|
+
const b = req.body ?? {};
|
|
56476
|
+
const category = b.category === "idea" || b.category === "question" ? b.category : "bug";
|
|
56477
|
+
const title = typeof b.title === "string" ? b.title.trim() : "";
|
|
56478
|
+
const body = typeof b.body === "string" ? b.body.trim() : "";
|
|
56479
|
+
if (!title || !body) {
|
|
56480
|
+
return reply.status(400).send({ ok: false, error: "title and body are required" });
|
|
56481
|
+
}
|
|
56482
|
+
const clamp = (s, n) => s.length > n ? s.slice(0, n) : s;
|
|
56483
|
+
const context = b.context && typeof b.context === "object" && !Array.isArray(b.context) && JSON.stringify(b.context).length <= 16e3 ? b.context : void 0;
|
|
56484
|
+
const result = await reportIssue({ category, title: clamp(title, 200), body: clamp(body, 12e3), context });
|
|
56485
|
+
if (result.ok) return { ok: true, ref: result.ref };
|
|
56486
|
+
const status = result.error === "signed_out" ? 401 : result.error === "rate_limited" ? 429 : 503;
|
|
56487
|
+
return reply.status(status).send({ ok: false, error: result.error });
|
|
56488
|
+
}
|
|
56489
|
+
);
|
|
56358
56490
|
app.get("/api/autostart", async () => {
|
|
56359
56491
|
const supported = autostartSupported();
|
|
56360
56492
|
return { ok: true, supported, enabled: supported ? autostartPresent() : false };
|
|
@@ -56614,6 +56746,30 @@ async function startServer() {
|
|
|
56614
56746
|
broadcast({ type: "grafted", id: stage.agentId });
|
|
56615
56747
|
return { ok: true, agentId: stage.agentId };
|
|
56616
56748
|
});
|
|
56749
|
+
async function detectCredentialIssue(id) {
|
|
56750
|
+
try {
|
|
56751
|
+
const app2 = readApp(id);
|
|
56752
|
+
const agents = new Set(app2.nodes.map((n) => n.agent).filter((a) => !!a));
|
|
56753
|
+
if (!agents.size) return null;
|
|
56754
|
+
const live = await aware.connectList();
|
|
56755
|
+
for (const c of live) {
|
|
56756
|
+
if (agents.has(c.integration) && c.status !== "valid") {
|
|
56757
|
+
return { id: c.integration, label: friendlyIntegrationName(c.integration) };
|
|
56758
|
+
}
|
|
56759
|
+
}
|
|
56760
|
+
} catch {
|
|
56761
|
+
}
|
|
56762
|
+
return null;
|
|
56763
|
+
}
|
|
56764
|
+
function hasAuthFailure(events) {
|
|
56765
|
+
for (const ev of events) {
|
|
56766
|
+
const data = ev.data;
|
|
56767
|
+
const result = data?.result ?? data;
|
|
56768
|
+
const status = result && typeof result.status === "number" ? result.status : null;
|
|
56769
|
+
if (status === 401 || status === 403) return true;
|
|
56770
|
+
}
|
|
56771
|
+
return false;
|
|
56772
|
+
}
|
|
56617
56773
|
app.post(
|
|
56618
56774
|
"/api/run",
|
|
56619
56775
|
async (req, reply) => {
|
|
@@ -56633,7 +56789,8 @@ async function startServer() {
|
|
|
56633
56789
|
}
|
|
56634
56790
|
const stderr = detail && typeof detail.stderr === "string" ? detail.stderr.trim() : "";
|
|
56635
56791
|
app.log.warn({ detail: err.detail }, err.message);
|
|
56636
|
-
|
|
56792
|
+
const credentialIssue2 = simulate ? null : await detectCredentialIssue(id);
|
|
56793
|
+
return reply.send({ ok: false, error: err.message, reason: stderr || null, simulate: !!simulate, credentialIssue: credentialIssue2 });
|
|
56637
56794
|
}
|
|
56638
56795
|
throw err;
|
|
56639
56796
|
}
|
|
@@ -56642,13 +56799,26 @@ async function startServer() {
|
|
|
56642
56799
|
for (const ev of events) broadcast({ type: "trace", id, event: ev });
|
|
56643
56800
|
broadcast({ type: "run-ended", id, runId: result.runId });
|
|
56644
56801
|
const report = extractReportHtml(events);
|
|
56645
|
-
|
|
56802
|
+
const credentialIssue = !simulate && hasAuthFailure(events) ? await detectCredentialIssue(id) : null;
|
|
56803
|
+
return { ok: true, runId: result.runId, tracePath: result.tracePath, events, report, credentialIssue };
|
|
56646
56804
|
}
|
|
56647
56805
|
);
|
|
56648
56806
|
app.post("/api/run/stop", async () => {
|
|
56649
56807
|
const running = cancelActiveRun();
|
|
56650
56808
|
return { ok: true, running };
|
|
56651
56809
|
});
|
|
56810
|
+
app.get("/api/latest-run/:id", async (req, reply) => {
|
|
56811
|
+
const id = req.params.id;
|
|
56812
|
+
if (!/^[A-Za-z0-9._-]+$/.test(id)) {
|
|
56813
|
+
return reply.status(400).send({ ok: false, error: "invalid app id" });
|
|
56814
|
+
}
|
|
56815
|
+
const latest = readLatestTrace(id);
|
|
56816
|
+
if (!latest) {
|
|
56817
|
+
return { ok: true, id, runId: null, status: "none", errorNodeId: null, errorMessage: null, events: [] };
|
|
56818
|
+
}
|
|
56819
|
+
const events = parseTrace(latest.text);
|
|
56820
|
+
return { ok: true, id, runId: latest.runId, ...summarizeRun(events), events };
|
|
56821
|
+
});
|
|
56652
56822
|
function resolveArgs(configArgs, inputs) {
|
|
56653
56823
|
const out = {};
|
|
56654
56824
|
for (const [k, v] of Object.entries(configArgs)) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: floless-app-report-issue
|
|
3
|
+
description: File a diagnosed bug / idea / question from floless.app into the PRIVATE product log via the floless.io relay. Use when the user invokes /floless-app-report-issue, clicks "Report an issue" in the floless.app UI (a queued report or a copied prompt), or says "report this", "file a bug", "something broke in floless", "send feedback / a feature idea". It READS the live run evidence first (latest run trace, failing node + error, the .flo, exec code), offers a workaround when one exists, then composes a maintainer-ready report and sends it — always showing the exact text and confirming first. Read-only: it never re-runs a workflow node.
|
|
4
|
+
metadata:
|
|
5
|
+
version: 0.1.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# floless.app — report an issue
|
|
9
|
+
|
|
10
|
+
You are not a feedback form. You are the **on-device incident responder**: a capable AI already
|
|
11
|
+
running on the user's machine, with its hands on the exact thing that broke — the workflow, the
|
|
12
|
+
run trace, the `.flo`, the exec code. A report you file should arrive **diagnosed, not described**.
|
|
13
|
+
|
|
14
|
+
The destination is floless.app's **private** product log. End users can't file there directly
|
|
15
|
+
(it's a private repo), so the local server **relays** the report to floless.io, which files it
|
|
16
|
+
on their behalf under the signed-in session. You compose + send; you never touch GitHub directly.
|
|
17
|
+
|
|
18
|
+
## Preconditions
|
|
19
|
+
|
|
20
|
+
- The local server is up: `curl -s http://127.0.0.1:4317/api/health` → `{"ok":true,…}` (fixed port
|
|
21
|
+
4317; override `$PORT`). It returns `appVersion`, `awareVersion`, and `license`. If it's down,
|
|
22
|
+
the user starts it from the repo (`cd server && npm run dev`).
|
|
23
|
+
- A signed-in session is required to file (the relay authenticates with it). If a send returns
|
|
24
|
+
`signed_out`, tell the user to sign in (menu → the license state in the footer) and retry — do
|
|
25
|
+
not try to work around it.
|
|
26
|
+
|
|
27
|
+
## Safety contract — READ-ONLY (non-negotiable)
|
|
28
|
+
|
|
29
|
+
Diagnosing must never change the user's model or data.
|
|
30
|
+
|
|
31
|
+
- **Never re-run an individual exec node.** Exec nodes run Roslyn C# against a **live Tekla/Trimble
|
|
32
|
+
host** and can mutate the model. There is no "re-run just this node" — don't invent one.
|
|
33
|
+
- **Only** offer a *full-workflow* re-run when **all** hold: the user explicitly asks, the app is
|
|
34
|
+
`runnable`, and the latest trace shows the previous run was **non-mutating** (read-only nodes).
|
|
35
|
+
Otherwise diagnose from the evidence already on disk.
|
|
36
|
+
- Everything below (`/api/health`, `/api/latest-run/:id`, `/api/app/:id`, reading the `.flo`/exec
|
|
37
|
+
source) is read-only. Stay there.
|
|
38
|
+
|
|
39
|
+
## Discriminator + routing — keep the trackers clean
|
|
40
|
+
|
|
41
|
+
Everything from a user funnels into the **one private floless log**; the maintainer triages and
|
|
42
|
+
escalates from there. The user never has to decide "is this AWARE or floless?".
|
|
43
|
+
|
|
44
|
+
- File here for: anything in the floless.app experience — the UI, a workflow/app behaving wrong, a
|
|
45
|
+
confusing result, a feature idea, a question.
|
|
46
|
+
- The one exception (maintainers only): if it is *plainly* an AWARE **runtime** defect (a compiler
|
|
47
|
+
panic, a CLI bug) **and** you're operating with repo access, prefer `/aware-log-issue`
|
|
48
|
+
(→ `aware-aeco`). For an ordinary user, still file here — the maintainer escalates.
|
|
49
|
+
|
|
50
|
+
## Workflow — read, diagnose, confirm, send
|
|
51
|
+
|
|
52
|
+
```dot
|
|
53
|
+
digraph { "health + identify app" -> "read evidence (READ-ONLY)" -> "root-cause hypothesis" ->
|
|
54
|
+
"offer workaround" -> "compose report" -> "privacy scrub + SHOW + confirm" ->
|
|
55
|
+
"POST /api/report-issue" -> "report the ref" }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
1. **Health + identify the app.** `GET /api/health` for versions + license. Establish which app/
|
|
59
|
+
workflow the report is about (ask if unclear, or take it from a queued UI request).
|
|
60
|
+
2. **Read the evidence (the killer step — READ-ONLY):**
|
|
61
|
+
- `GET /api/latest-run/<appId>` → structured trace: `runId`, `status`, `errorNodeId`,
|
|
62
|
+
`errorMessage`, `events`. This is the spine of the diagnosis.
|
|
63
|
+
- `GET /api/app/<appId>` → the normalized topology + `.flo` source + each node's `config`
|
|
64
|
+
(incl. exec `code`). Read the failing node's code; pin `file:line`.
|
|
65
|
+
- Form a concrete **root-cause hypothesis** — not "it failed", but *why*.
|
|
66
|
+
3. **Offer a fix/workaround in the moment** when the evidence supports one. The user often leaves
|
|
67
|
+
already unstuck — that's the point.
|
|
68
|
+
4. **Compose** the report from the template below: a diagnosed body, problem-first title.
|
|
69
|
+
5. **Privacy scrub + confirm (ALWAYS).** The private log is still real: include versions + a
|
|
70
|
+
sanitized summary by default. **Ask before** attaching the `.flo`, raw logs, a screenshot, or
|
|
71
|
+
any model/company names. **Show the user the exact title + body**, then wait for an explicit
|
|
72
|
+
yes. Nothing sends without it.
|
|
73
|
+
6. **Send** via the local relay:
|
|
74
|
+
```bash
|
|
75
|
+
curl -s -X POST http://127.0.0.1:4317/api/report-issue \
|
|
76
|
+
-H 'Content-Type: application/json' \
|
|
77
|
+
-d '{"category":"bug","title":"…","body":"…","context":{ "appId":"…","appVersion":"…","awareVersion":"…","runId":"…","errorNodeId":"…" }}'
|
|
78
|
+
```
|
|
79
|
+
→ `{ "ok": true, "ref": "#123" }`. Errors: `signed_out` (sign in), `rate_limited` (wait), or
|
|
80
|
+
`offline` (check connection) — relay them plainly; the user's text is not lost.
|
|
81
|
+
7. **Report** the `ref` to the user, plus any workaround you already gave. Because the repo is
|
|
82
|
+
private, there is no link to open — say so; the maintainer follows up.
|
|
83
|
+
|
|
84
|
+
### Local API
|
|
85
|
+
|
|
86
|
+
| Call | Purpose |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `GET /api/health` | versions + license state |
|
|
89
|
+
| `GET /api/latest-run/:id` | **read-only** structured trace of the app's most recent run |
|
|
90
|
+
| `GET /api/app/:id` | normalized topology + `.flo` source + node config/exec code |
|
|
91
|
+
| `POST /api/report-issue` | send the composed report through the relay → `{ ok, ref }` |
|
|
92
|
+
|
|
93
|
+
## Report body template
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
**Summary** — <one line, problem-first>
|
|
97
|
+
|
|
98
|
+
**Observed** (with evidence)
|
|
99
|
+
- <what happened> — node `<errorNodeId>` · `<errorMessage>`
|
|
100
|
+
- <file:line in the exec code, quoted, when relevant>
|
|
101
|
+
|
|
102
|
+
**Root-cause hypothesis**
|
|
103
|
+
- <why it happened — your diagnosis, marked as hypothesis if unconfirmed>
|
|
104
|
+
|
|
105
|
+
**Repro**
|
|
106
|
+
- app `<appId>` (FloLess v<appVersion>, AWARE v<awareVersion>), run `<runId>`
|
|
107
|
+
- <steps / inputs> ← attach the .flo only with consent
|
|
108
|
+
|
|
109
|
+
**Expected**
|
|
110
|
+
- <what AWARE/floless should have done>
|
|
111
|
+
|
|
112
|
+
**Workaround given** (if any)
|
|
113
|
+
- <what you told the user to do right now>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The local route stamps category → label and the relay adds `user-report` + the reporter id.
|
|
117
|
+
|
|
118
|
+
## Guardrails
|
|
119
|
+
|
|
120
|
+
- **Read-only.** Diagnose from evidence on disk; never re-run a node (live-host mutation risk).
|
|
121
|
+
- **Relay only.** This skill never calls `gh` — the user has no access to the private repo; the
|
|
122
|
+
relay files on their behalf. (The maintainer `gh` runbook is the separate `floless-app-log-issue`.)
|
|
123
|
+
- **Always confirm** the exact text before sending; default to versions + sanitized summary, and
|
|
124
|
+
ask before attaching anything richer.
|
|
125
|
+
- **Don't invent** node ids, errors, or a fix you can't support from the evidence. A wrong-but-
|
|
126
|
+
confident diagnosis is worse than an honest "couldn't reproduce — here's what I see."
|
|
127
|
+
- **One report per concern.** Don't bundle a bug and a feature idea into one.
|
package/dist/web/app.css
CHANGED
|
@@ -987,6 +987,9 @@
|
|
|
987
987
|
background: var(--accent-bright);
|
|
988
988
|
box-shadow: 0 0 14px var(--accent-glow);
|
|
989
989
|
}
|
|
990
|
+
/* A disabled action reads as inert — never a primary that looks clickable but isn't. */
|
|
991
|
+
.modal-actions button:disabled { opacity: 0.5; cursor: default; }
|
|
992
|
+
.modal-actions button.primary:disabled:hover { background: var(--accent); box-shadow: none; }
|
|
990
993
|
.modal.library {
|
|
991
994
|
width: 720px;
|
|
992
995
|
max-height: 82vh;
|
|
@@ -2065,6 +2068,42 @@ body {
|
|
|
2065
2068
|
}
|
|
2066
2069
|
.report-overlay .overlay-stop:disabled { opacity: 0.6; cursor: default; }
|
|
2067
2070
|
|
|
2071
|
+
/* Credential-failure reconnect card — shown in .report-overlay when a run hits a
|
|
2072
|
+
rejected OAuth token. Amber-topped (recoverable, needs attention); the Reconnect
|
|
2073
|
+
action keeps primary-blue (the one critical next step), Open Integrations is ghost. */
|
|
2074
|
+
.cred-fail-card {
|
|
2075
|
+
background: var(--surface); border: 1px solid var(--border-strong); border-top: 3px solid var(--warn);
|
|
2076
|
+
border-radius: 8px; padding: 22px 24px; max-width: 380px; width: 100%;
|
|
2077
|
+
display: flex; flex-direction: column; gap: 14px; text-align: left;
|
|
2078
|
+
}
|
|
2079
|
+
.cred-fail-status {
|
|
2080
|
+
display: flex; align-items: center; gap: 8px;
|
|
2081
|
+
font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim);
|
|
2082
|
+
}
|
|
2083
|
+
.cred-fail-icon { color: var(--warn); font-size: 16px; line-height: 1; }
|
|
2084
|
+
.cred-fail-headline { font-size: 15px; font-weight: 600; color: var(--text); }
|
|
2085
|
+
.cred-fail-body { font-size: 13px; color: var(--text-muted); line-height: 1.55; }
|
|
2086
|
+
.cred-fail-err { font-size: 12px; color: var(--err); }
|
|
2087
|
+
.cred-fail-ok { font-size: 13px; color: var(--ok); font-weight: 600; }
|
|
2088
|
+
.cred-fail-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
|
2089
|
+
.cred-fail-actions button { font-family: var(--ui); font-size: 13px; line-height: 1.2; border-radius: 4px; cursor: pointer; }
|
|
2090
|
+
.cred-fail-actions .cred-reconnect {
|
|
2091
|
+
padding: 7px 16px; background: var(--accent); color: white; border: 1px solid var(--accent); font-weight: 600;
|
|
2092
|
+
}
|
|
2093
|
+
.cred-fail-actions .cred-reconnect:hover:not(:disabled) {
|
|
2094
|
+
background: var(--accent-bright); box-shadow: 0 0 14px var(--accent-glow);
|
|
2095
|
+
}
|
|
2096
|
+
.cred-fail-actions .cred-reconnect:disabled { opacity: 0.7; cursor: default; }
|
|
2097
|
+
.cred-fail-actions .cred-open-integrations {
|
|
2098
|
+
padding: 7px 12px; background: transparent; border: 1px solid var(--border-strong); color: var(--text-muted);
|
|
2099
|
+
}
|
|
2100
|
+
.cred-fail-actions .cred-open-integrations:hover { color: var(--text); border-color: var(--accent-dim); }
|
|
2101
|
+
.cred-reconnect-spinner {
|
|
2102
|
+
display: inline-block; width: 12px; height: 12px; vertical-align: -2px; margin-right: 6px;
|
|
2103
|
+
border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: #fff; border-radius: 50%;
|
|
2104
|
+
animation: spin 0.8s linear infinite;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2068
2107
|
/* Report + input nodes on the canvas carry a first-class action button
|
|
2069
2108
|
("View report ▸" / "Set inputs ▸"). Double-click still works as a shortcut. */
|
|
2070
2109
|
.agent-card.report-node,
|
package/dist/web/app.js
CHANGED
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)
|
|
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
|
|
|
@@ -959,6 +963,9 @@
|
|
|
959
963
|
: await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate: false, inputs }) });
|
|
960
964
|
// Reflect the run in the Execution tab too.
|
|
961
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; }
|
|
962
969
|
const html = res.report && res.report.html;
|
|
963
970
|
if (!html) {
|
|
964
971
|
$reportOverlay.innerHTML = `<div>Run completed but <code>${escapeHtml(nodeId)}</code> returned no <code>html</code>. Check the Execution tab.</div>`;
|
|
@@ -977,17 +984,115 @@
|
|
|
977
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>`;
|
|
978
985
|
showToast('Run cancelled', 'info');
|
|
979
986
|
} else {
|
|
980
|
-
// Expected, recoverable failure
|
|
981
|
-
//
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
+
}
|
|
985
999
|
}
|
|
986
1000
|
} finally {
|
|
987
1001
|
reportRunning = false;
|
|
988
1002
|
}
|
|
989
1003
|
}
|
|
990
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
|
+
|
|
991
1096
|
function showModal(el) { el.classList.add('show'); }
|
|
992
1097
|
function hideModal(el) { el.classList.remove('show'); }
|
|
993
1098
|
|
|
@@ -1622,6 +1727,8 @@
|
|
|
1622
1727
|
const res = await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate, inputs: currentInputs() }) });
|
|
1623
1728
|
// SSE usually fills liveTrace first; backfill from the response if it didn't.
|
|
1624
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; }
|
|
1625
1732
|
switchToExecution();
|
|
1626
1733
|
const steps = liveTrace.filter((r) => r.kind === 'node-start').length;
|
|
1627
1734
|
appendNarration(simulate
|
|
@@ -1634,12 +1741,18 @@
|
|
|
1634
1741
|
showToast('Run cancelled', 'info');
|
|
1635
1742
|
appendNarration('Run cancelled. Click <strong>▶ Run workflow</strong> to run again.');
|
|
1636
1743
|
} else {
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
+
}
|
|
1643
1756
|
}
|
|
1644
1757
|
} finally {
|
|
1645
1758
|
state.running = false;
|
|
@@ -2215,6 +2328,12 @@
|
|
|
2215
2328
|
} else if (m.type === 'trigger-session-changed') {
|
|
2216
2329
|
applyTriggerSnapshot(m.id, m.snapshot);
|
|
2217
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
|
+
}
|
|
2218
2337
|
// The device-code sign-in resolved. On success, refresh so the card flips
|
|
2219
2338
|
// to Connected; otherwise surface the outcome in the integration's slot.
|
|
2220
2339
|
connecting.delete(m.id);
|
|
@@ -3147,6 +3266,188 @@
|
|
|
3147
3266
|
else if ($routinesModal.classList.contains('show')) hideModal($routinesModal);
|
|
3148
3267
|
});
|
|
3149
3268
|
|
|
3269
|
+
// ── Report an issue ──────────────────────────────────────────────────────────
|
|
3270
|
+
// The hamburger "Report an issue" item files into the PRIVATE product log via
|
|
3271
|
+
// POST /api/report-issue (the floless.io relay; report-relay.ts). The user gets a
|
|
3272
|
+
// confirmation ref, not a public link (private repo). The /floless-app-report-issue
|
|
3273
|
+
// skill is the richer terminal path (it reads run traces + the .flo to file a
|
|
3274
|
+
// diagnosed report). Modal elements are static in index.html, present when this runs.
|
|
3275
|
+
const $riModal = document.getElementById('report-issue-modal');
|
|
3276
|
+
const $riForm = document.getElementById('ri-form');
|
|
3277
|
+
const $riState = document.getElementById('ri-state');
|
|
3278
|
+
const $riTitle = document.getElementById('ri-title');
|
|
3279
|
+
const $riDesc = document.getElementById('ri-desc');
|
|
3280
|
+
const $riContext = document.getElementById('ri-context');
|
|
3281
|
+
const $riSend = document.getElementById('ri-send');
|
|
3282
|
+
const $riCancel = document.getElementById('ri-cancel');
|
|
3283
|
+
let riCategory = 'bug';
|
|
3284
|
+
let riSubmitting = false;
|
|
3285
|
+
let riAppVersion = ''; // captured from /api/health on open so the report actually carries
|
|
3286
|
+
let riAwareVersion = ''; // the versions the modal promises ("sent automatically: …")
|
|
3287
|
+
|
|
3288
|
+
const riSyncSend = () => { $riSend.disabled = !($riTitle.value.trim() && $riDesc.value.trim()); };
|
|
3289
|
+
const riClose = () => { if (!riSubmitting) hideModal($riModal); };
|
|
3290
|
+
const riSetCategory = (cat) => {
|
|
3291
|
+
riCategory = cat;
|
|
3292
|
+
$riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => {
|
|
3293
|
+
const on = b.dataset.cat === cat;
|
|
3294
|
+
b.classList.toggle('active', on);
|
|
3295
|
+
b.setAttribute('aria-pressed', String(on));
|
|
3296
|
+
});
|
|
3297
|
+
};
|
|
3298
|
+
// Build the post-submit states with safe DOM methods (textContent, not innerHTML).
|
|
3299
|
+
const riButton = (id, label, primary, onclick) => {
|
|
3300
|
+
const b = document.createElement('button');
|
|
3301
|
+
b.id = id;
|
|
3302
|
+
if (primary) b.className = 'primary';
|
|
3303
|
+
b.textContent = label;
|
|
3304
|
+
b.onclick = onclick;
|
|
3305
|
+
return b;
|
|
3306
|
+
};
|
|
3307
|
+
// A Lucide-style circle-check (stroke SVG, matching the menu icons) for the success state —
|
|
3308
|
+
// it actually means "received/done", vs an ad-hoc glyph. Inherits var(--ok) via currentColor.
|
|
3309
|
+
const riCheckIcon = () => {
|
|
3310
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
3311
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
3312
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
3313
|
+
svg.setAttribute('width', '34');
|
|
3314
|
+
svg.setAttribute('height', '34');
|
|
3315
|
+
svg.setAttribute('fill', 'none');
|
|
3316
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
3317
|
+
svg.setAttribute('stroke-width', '1.75');
|
|
3318
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
3319
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
3320
|
+
svg.style.display = 'block';
|
|
3321
|
+
svg.style.margin = '0 auto';
|
|
3322
|
+
const circle = document.createElementNS(NS, 'circle');
|
|
3323
|
+
circle.setAttribute('cx', '12');
|
|
3324
|
+
circle.setAttribute('cy', '12');
|
|
3325
|
+
circle.setAttribute('r', '10');
|
|
3326
|
+
const check = document.createElementNS(NS, 'path');
|
|
3327
|
+
check.setAttribute('d', 'm9 12 2 2 4-4');
|
|
3328
|
+
svg.append(circle, check);
|
|
3329
|
+
return svg;
|
|
3330
|
+
};
|
|
3331
|
+
const riShowState = (...nodes) => {
|
|
3332
|
+
while ($riState.firstChild) $riState.removeChild($riState.firstChild);
|
|
3333
|
+
for (const n of nodes) $riState.appendChild(n);
|
|
3334
|
+
$riState.hidden = false;
|
|
3335
|
+
$riForm.hidden = true;
|
|
3336
|
+
};
|
|
3337
|
+
|
|
3338
|
+
openReportIssue = function openReportIssueReal() {
|
|
3339
|
+
riSubmitting = false;
|
|
3340
|
+
$riForm.hidden = false;
|
|
3341
|
+
$riState.hidden = true;
|
|
3342
|
+
while ($riState.firstChild) $riState.removeChild($riState.firstChild);
|
|
3343
|
+
$riTitle.value = '';
|
|
3344
|
+
$riDesc.value = '';
|
|
3345
|
+
riSetCategory('bug');
|
|
3346
|
+
riAppVersion = '';
|
|
3347
|
+
riAwareVersion = '';
|
|
3348
|
+
$riSend.disabled = true;
|
|
3349
|
+
$riSend.textContent = 'Send report';
|
|
3350
|
+
// Auto-attached diagnostics line — versions + the open workflow, read fresh from health.
|
|
3351
|
+
$riContext.textContent = 'Also sent automatically: app + AWARE version, and the open workflow.';
|
|
3352
|
+
api('/api/health').then((h) => {
|
|
3353
|
+
riAppVersion = h.appVersion || '';
|
|
3354
|
+
riAwareVersion = h.awareVersion || '';
|
|
3355
|
+
const app = currentId && apps.get(currentId);
|
|
3356
|
+
const wf = app ? ` · workflow "${app.displayName || currentId}"` : '';
|
|
3357
|
+
$riContext.textContent = `Also sent automatically: FloLess v${h.appVersion || '?'}, AWARE v${h.awareVersion || '?'}${wf}`;
|
|
3358
|
+
}).catch(() => { /* keep the generic line on a health blip */ });
|
|
3359
|
+
showModal($riModal);
|
|
3360
|
+
setTimeout(() => $riTitle.focus(), 0);
|
|
3361
|
+
};
|
|
3362
|
+
|
|
3363
|
+
function riRenderSuccess(ref) {
|
|
3364
|
+
const wrap = document.createElement('div');
|
|
3365
|
+
wrap.className = 'graft-success';
|
|
3366
|
+
const icon = document.createElement('div');
|
|
3367
|
+
icon.className = 'gs-icon';
|
|
3368
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
3369
|
+
icon.appendChild(riCheckIcon());
|
|
3370
|
+
const msg = document.createElement('div');
|
|
3371
|
+
msg.className = 'gs-msg';
|
|
3372
|
+
msg.textContent = ref
|
|
3373
|
+
? `Received — logged as ${ref} · We'll follow up privately.`
|
|
3374
|
+
: "Received · We'll follow up privately.";
|
|
3375
|
+
wrap.append(icon, msg);
|
|
3376
|
+
const actions = document.createElement('div');
|
|
3377
|
+
actions.className = 'modal-actions';
|
|
3378
|
+
const done = riButton('ri-done', 'Done', true, () => hideModal($riModal));
|
|
3379
|
+
actions.append(done);
|
|
3380
|
+
riShowState(wrap, actions);
|
|
3381
|
+
done.focus();
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
function riRenderSignIn() {
|
|
3385
|
+
const sub = document.createElement('div');
|
|
3386
|
+
sub.className = 'modal-sub';
|
|
3387
|
+
sub.textContent = 'Sign in to send a report — your session has expired.';
|
|
3388
|
+
const actions = document.createElement('div');
|
|
3389
|
+
actions.className = 'modal-actions';
|
|
3390
|
+
const close = riButton('ri-signin-close', 'Close', false, () => hideModal($riModal));
|
|
3391
|
+
const signin = riButton('ri-signin-btn', 'Sign in', true, async () => {
|
|
3392
|
+
try { await api('/api/license/start', { method: 'POST' }); } catch { /* surfaced via license polling */ }
|
|
3393
|
+
hideModal($riModal);
|
|
3394
|
+
});
|
|
3395
|
+
actions.append(close, signin);
|
|
3396
|
+
riShowState(sub, actions);
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
async function riSubmit() {
|
|
3400
|
+
if (riSubmitting) return;
|
|
3401
|
+
const title = $riTitle.value.trim();
|
|
3402
|
+
const body = $riDesc.value.trim();
|
|
3403
|
+
if (!title || !body) return;
|
|
3404
|
+
riSubmitting = true;
|
|
3405
|
+
$riSend.disabled = true;
|
|
3406
|
+
$riSend.textContent = 'Sending…';
|
|
3407
|
+
// Versions are filled by the health fetch on open; if a fast submit beat it, fetch them
|
|
3408
|
+
// now so the report carries the versions the modal promised ("sent automatically: …").
|
|
3409
|
+
if (!riAppVersion || !riAwareVersion) {
|
|
3410
|
+
try {
|
|
3411
|
+
const h = await api('/api/health');
|
|
3412
|
+
riAppVersion = riAppVersion || h.appVersion || '';
|
|
3413
|
+
riAwareVersion = riAwareVersion || h.awareVersion || '';
|
|
3414
|
+
} catch { /* best-effort — send with whatever we have */ }
|
|
3415
|
+
}
|
|
3416
|
+
const app = currentId && apps.get(currentId);
|
|
3417
|
+
const context = {
|
|
3418
|
+
source: 'web',
|
|
3419
|
+
appId: currentId || null,
|
|
3420
|
+
workflow: app ? (app.displayName || currentId) : null,
|
|
3421
|
+
appVersion: riAppVersion || null,
|
|
3422
|
+
awareVersion: riAwareVersion || null,
|
|
3423
|
+
};
|
|
3424
|
+
try {
|
|
3425
|
+
const r = await api('/api/report-issue', { method: 'POST', body: JSON.stringify({ category: riCategory, title, body, context }) });
|
|
3426
|
+
riSubmitting = false; // response in hand — Done / Escape / backdrop may close
|
|
3427
|
+
riRenderSuccess(r.ref || '');
|
|
3428
|
+
} catch (e) {
|
|
3429
|
+
riSubmitting = false;
|
|
3430
|
+
const err = (e && e.body && e.body.error) || '';
|
|
3431
|
+
if (err === 'signed_out') {
|
|
3432
|
+
riRenderSignIn(); // the action isn't possible until signed in — hide the form, don't grey it out
|
|
3433
|
+
} else if (err === 'rate_limited') {
|
|
3434
|
+
showToast('Too many reports just now — please wait a moment and try again', 'warn');
|
|
3435
|
+
$riSend.disabled = false; $riSend.textContent = 'Send report';
|
|
3436
|
+
} else {
|
|
3437
|
+
showToast("Couldn't send — check your connection and try again", 'err');
|
|
3438
|
+
$riSend.disabled = false; $riSend.textContent = 'Send report';
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
$riTitle.addEventListener('input', riSyncSend);
|
|
3444
|
+
$riDesc.addEventListener('input', riSyncSend);
|
|
3445
|
+
$riSend.onclick = () => riSubmit();
|
|
3446
|
+
$riCancel.onclick = () => riClose();
|
|
3447
|
+
$riModal.onclick = (e) => { if (e.target === $riModal) riClose(); };
|
|
3448
|
+
$riModal.querySelectorAll('#ri-category .rtn-mode-btn').forEach((b) => { b.onclick = () => riSetCategory(b.dataset.cat); });
|
|
3449
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $riModal.classList.contains('show')) riClose(); });
|
|
3450
|
+
|
|
3150
3451
|
// ── init (after app.js bootstrap) ────────────────────────────────────────────
|
|
3151
3452
|
// app.js already stored the ORIGINAL functions as .onclick/.oninput before we
|
|
3152
3453
|
// reassigned the globals; rebind via arrows so they resolve our versions at
|
package/dist/web/index.html
CHANGED
|
@@ -191,6 +191,11 @@
|
|
|
191
191
|
<span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><line x1="10" x2="14" y1="2" y2="2"/><line x1="12" x2="15" y1="14" y2="11"/><circle cx="12" cy="14" r="8"/></svg></span>
|
|
192
192
|
<span class="menu-label">Routines</span>
|
|
193
193
|
</button>
|
|
194
|
+
<div class="menu-divider"></div>
|
|
195
|
+
<button class="menu-item" data-action="report-issue" role="menuitem">
|
|
196
|
+
<span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/></svg></span>
|
|
197
|
+
<span class="menu-label">Report an issue</span>
|
|
198
|
+
</button>
|
|
194
199
|
<!-- Start-on-login + Theme are sibling machine preferences (no divider between
|
|
195
200
|
them — UX review 2026-05-31). Start-on-login toggles the per-user logon
|
|
196
201
|
Scheduled Task that keeps the local server alive across logins; hidden unless
|
|
@@ -428,6 +433,40 @@
|
|
|
428
433
|
</div>
|
|
429
434
|
</div>
|
|
430
435
|
|
|
436
|
+
<!-- Report an issue — files into the PRIVATE product log via POST /api/report-issue
|
|
437
|
+
(the floless.io relay; report-relay.ts). The user gets a confirmation ref, not a
|
|
438
|
+
public link (the repo is private). JS swaps #ri-form for #ri-state (success / sign-in). -->
|
|
439
|
+
<div class="modal-backdrop" id="report-issue-modal">
|
|
440
|
+
<div class="modal report-issue">
|
|
441
|
+
<div class="modal-title">Report an issue</div>
|
|
442
|
+
<div class="modal-sub">Reports go to a private log — you won't get a public link to track it. We'll follow up with you if we need more details.</div>
|
|
443
|
+
<div id="ri-form">
|
|
444
|
+
<div class="modal-field">
|
|
445
|
+
<label>Category</label>
|
|
446
|
+
<div class="rtn-mode" id="ri-category" role="group" aria-label="Report category">
|
|
447
|
+
<button type="button" class="rtn-mode-btn active" data-cat="bug" aria-pressed="true">Bug</button>
|
|
448
|
+
<button type="button" class="rtn-mode-btn" data-cat="idea" aria-pressed="false">Idea</button>
|
|
449
|
+
<button type="button" class="rtn-mode-btn" data-cat="question" aria-pressed="false">Question</button>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
<div class="modal-field">
|
|
453
|
+
<label for="ri-title">Title</label>
|
|
454
|
+
<input id="ri-title" type="text" placeholder="Summarise the issue in a few words" autocomplete="off">
|
|
455
|
+
</div>
|
|
456
|
+
<div class="modal-field">
|
|
457
|
+
<label for="ri-desc">Description</label>
|
|
458
|
+
<textarea id="ri-desc" placeholder="What happened? What did you expect? Steps to reproduce if it's a bug."></textarea>
|
|
459
|
+
</div>
|
|
460
|
+
<div class="integrations-hint" id="ri-context"></div>
|
|
461
|
+
<div class="modal-actions">
|
|
462
|
+
<button id="ri-cancel">Cancel</button>
|
|
463
|
+
<button id="ri-send" class="primary" disabled>Send report</button>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
<div id="ri-state" hidden></div>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
|
|
431
470
|
<!-- Routine delete confirmation — destructive, so Cancel holds focus (Enter cancels)
|
|
432
471
|
and the Delete button is danger-on-hover, never the accent primary. -->
|
|
433
472
|
<div class="modal-backdrop" id="rtn-delete-modal">
|