@bivy/bivy 0.20.7-staging.3 → 0.20.7-staging.4
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/bin/bivy.mjs +61 -0
- package/dist/apps/backend/compare.js +62 -0
- package/dist/apps/backend/http-file.js +66 -0
- package/dist/apps/backend/index.js +431 -0
- package/dist/apps/backend/logs.js +60 -0
- package/dist/apps/backend/rows.js +79 -0
- package/dist/apps/gateway.js +30 -0
- package/dist/apps/preview-shell.js +24 -4
- package/dist/apps/registry.js +56 -1
- package/dist/apps/service.js +37 -2
- package/dist/apps/styles.css +66 -0
- package/dist/apps/types.js +7 -0
- package/dist/controllers/app-commands.js +10 -0
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +7 -0
- package/dist/server.js +2 -1
- package/dist/terminal.js +17 -0
- package/package.json +1 -1
- package/web/assets/{AutomationsView-DuWxbo5B.js → AutomationsView-DJibSXQw.js} +1 -1
- package/web/assets/{ChangesCard-BnHllUb0.js → ChangesCard-s8Qtm7eu.js} +1 -1
- package/web/assets/{LibraryView-DpNuD-T7.js → LibraryView-Bz8x-H8N.js} +2 -2
- package/web/assets/{ProviderConnect-4vjf567A.js → ProviderConnect-DZ-Rv8Aj.js} +1 -1
- package/web/assets/{Settings-CHofnxyn.js → Settings-DMUdD3O5.js} +3 -3
- package/web/assets/{Sheet-DwouI-Tt.js → Sheet-2wuqvpxm.js} +1 -1
- package/web/assets/{Terminal-3mNEW7UT.js → Terminal-DQeYK2KK.js} +1 -1
- package/web/assets/{VoiceSettings-BI4B-zS1.js → VoiceSettings-BbqFD1Ee.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-DPP1TRyh.js → abnfDiagram-VCTEODGH-DLSpnM9y.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-CefLtGFG.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-CvgInp30.js → architectureDiagram-5GKGNRK7-DOQxmFrp.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-CZAAYK-I.js → blockDiagram-I7D4REHJ-BiCBLtEP.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-BYJlvZiZ.js → c4Diagram-7LVT6UL2-yzmqXo9k.js} +1 -1
- package/web/assets/channel-kKuds40w.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-Cj4-BAMD.js → chunk-4HAMMTFA-i-QksGpD.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-3eT-aS0t.js → chunk-75Z2AOVW-DWjggoX1.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-D_4JI7OY.js → chunk-DU6HZSFF-jFwsC9ox.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-5wTG02yk.js → chunk-F27PBJKO-3ej2B9gg.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-IliCLU5B.js → chunk-GMAD6QVW-CzfhoJ04.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-CB3DCzUu.js → chunk-GVQU2GXP-DLhbmKuJ.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-Bg3eY-xc.js → chunk-IMKFNOWR-dR96ErOJ.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-BEMaPYT9.js → chunk-L3NEJ4N5-1YX2kWHP.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-DMc-FLYk.js → chunk-OSK3NFVY-kDveSID9.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-CaqPruPs.js → chunk-P2QGCYS3-Cc3Uo4HQ.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-3h-I9lV1.js → chunk-POPQ4Y6H-CxDXuH1x.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-CPLNMq3b.js → chunk-PWAF6VOD-DZBN6HSl.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-ErtwLBXR.js → chunk-SHT3W25Y-0W01Q-w7.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-GNUjH0zF.js → chunk-SVP7TREG-gqFFZT4I.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-DtbyVpEE.js → chunk-TICWLB2K-BGdM7dkO.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-Dg-2WnlQ.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-Dg-2WnlQ.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-CQt68k-b.js → cynefin-OW5HDTMX-D3KvEku4.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-BeEQcUmX.js → cynefinDiagram-5FMLGOSQ-CLbDBHX9.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-CoekQT_h.js → dagre-GXQ25YYZ-D2kdt2lo.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-DQBF8H5Z.js → diagram-S7CK7UJ4-a-ev_USx.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-2Tw1WGQF.js → diagram-UQ7AKVKN-BwYRbF-y.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-C4Ns08EL.js → diagram-VSXAHHWV-Da7O55Aq.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-CMUQSfon.js → diagram-VX7I27RA-Bcq-P_8M.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-Lqvr8Yf6.js → diagram-Z3DM3KII-DSXXMWvA.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-BAYJ-dgh.js → ebnfDiagram-PWID7BFC-DxFerrqi.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-Dg8VYnq4.js → erDiagram-RLTQ6QDP-DfyXAe2v.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-tdyiDQoh.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-BO23Cpex.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-ywnauJ7D.js → ganttDiagram-EL5Y4UJY-QkgrgMh9.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-bYI8lc-g.js → gitGraph-4MIJSDKK-M1n5msce.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-erBDwxX8.js → gitGraphDiagram-WWUBYQGX-DC_8Cxet.js} +1 -1
- package/web/assets/{index-DzT1Tnf2.js → index-B0Bto4kI.js} +2 -2
- package/web/assets/{index-Ch1zstZB.css → index-Da2-4YHh.css} +1 -1
- package/web/assets/{info-A6RAGUB7-BZbsPdZJ.js → info-A6RAGUB7-DmyYG6Ip.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-qqTv-8cL.js → infoDiagram-27XIBGKW-CmrMjsqT.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-v-e3YR9X.js → ishikawaDiagram-5VMMS53U-x_6jWkNq.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-D61ZGndw.js → journeyDiagram-3NMN7TZE-COVPGUIl.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-NeYWFrdx.js → kanban-definition-UXKFOSKX-BDZHzeOz.js} +1 -1
- package/web/assets/{line-BNxdvAy9.js → line-KdqoiWjk.js} +1 -1
- package/web/assets/{mermaid-parser.core-BjJ-qoss.js → mermaid-parser.core-C5RHAle0.js} +3 -3
- package/web/assets/{mermaid.core-DT4QwB_n.js → mermaid.core-2ujtWQt3.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-BxuVgGhr.js → mindmap-definition-YA3MSWOX-CCPoAcT0.js} +1 -1
- package/web/assets/mount-CaFlQTsV.js +30 -0
- package/web/assets/{packet-AYTQ26CC-DIff_vXS.js → packet-AYTQ26CC-BjPan2xl.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-oDNo32c7.js → pegDiagram-XKGWAZYB-CrARxusS.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-C0cdo_NU.js → pie-WAS4IAKB-BdVo8hLO.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-BbThivUY.js → pieDiagram-E7YTZNPT-DwdhmBCX.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-CONfSoMt.js → quadrantDiagram-AXDQQJYC-DbG3R6xw.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-teYstrbs.js → radar-RG4KPBEZ-pp-coSgM.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-B4VusJHl.js → railroad-74A4TZTK-CxNBhSbO.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-g5eNqQ81.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-BI5_cD5r.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-CLl7RlXh.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-CKrYutQ_.js → railroadDiagram-O6MQD6OU-CKbKBwCj.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-COt2S2Y4.js → requirementDiagram-BXWQKSXE-DRc316xB.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-D72oeNbf.js → sankeyDiagram-P5KCCOFB-C6O9zcIS.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-DFpVcNjb.js → sequenceDiagram-WJ2MYXX4-D3Vz1DcK.js} +1 -1
- package/web/assets/{settingsRoute-Clztmzl_.js → settingsRoute-DxPtfqmn.js} +1 -1
- package/web/assets/{stateDiagram-D77RDMKH-BKW8l6Ru.js → stateDiagram-D77RDMKH-DXcUXP-l.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-CQER-whG.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-GRXgsapd.js → swimlanes-42K2YHIH-tfw_N04-.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-Cf__qhQx.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-C8DELzYs.js → timeline-definition-24CTP7MA-BcAeG9F_.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-C1MDeOPi.js → treeView-Q6P3EWNA-DaZCFJDf.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-CcwgMCe9.js → treemap-WGGIJYW6-C3PybXBI.js} +1 -1
- package/web/assets/{useStore-CaQamKgO.js → useStore-Ce5mzYro.js} +8 -8
- package/web/assets/{vennDiagram-4TSXK5OY-Co-Nw6M-.js → vennDiagram-4TSXK5OY-BT7w_l5a.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-FbKVF2Pu.js → wardley-WFR3VGLG-CXZlxUiB.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4-Bt7NGXJX.js → wardleyDiagram-VM6X3IG4-B5muPnst.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-BsgyT9WB.js → xychartDiagram-S5SC5T6Z-DSaMhJlg.js} +1 -1
- package/web/index.html +2 -2
- package/web/sw.js +1 -1
- package/web/assets/architecture-7GRP2DOG-DUHnGffN.js +0 -1
- package/web/assets/channel-vvvNMjoB.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-C3eKmWom.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-C3eKmWom.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-ChDRNWM1.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-PojVb4l3.js +0 -1
- package/web/assets/mount-CK5h6xJ4.js +0 -27
- package/web/assets/railroad-abnf-HS5TGJTU-DIF_YU01.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-BoHyJK5s.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-uG1Bcrgs.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-C1t9bUgR.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-Dp35pflJ.js +0 -8
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { redactSecrets } from "../../redact.js";
|
|
4
|
+
const MAX_LINES = 2000;
|
|
5
|
+
const MAX_MARKS = 50;
|
|
6
|
+
const MAX_LINE = 2000;
|
|
7
|
+
/** How bad a line is, as rows tried in order. A server's own words decide:
|
|
8
|
+
* "error", an exception, a 5xx it answered. Adding a pattern is adding a row. */
|
|
9
|
+
const LEVELS = [
|
|
10
|
+
{ level: "error", pattern: /\b(error|exception|fatal|panic|traceback|uncaught|unhandled|failed)\b|[a-z](?:Error|Exception)\b|\b(?:Completed|status[=: ]+|HTTP\/\d(?:\.\d)?"?\s+)5\d\d\b|\s5\d\d\s+in\s+\d/i },
|
|
11
|
+
{ level: "warn", pattern: /\b(warn|warning|deprecat\w*)\b/i },
|
|
12
|
+
];
|
|
13
|
+
// Terminal colour and cursor codes: a log is read as text.
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
16
|
+
/** A stack frame or continuation belongs to the line above it. */
|
|
17
|
+
const CONTINUATION = /^(\s+\S|\s*at\s|\s*from\s|\s*File\s"|Caused by:|\s*\.\.\.\s\d+ more)/;
|
|
18
|
+
/** One source's recent lines, each stamped when it arrived. */
|
|
19
|
+
export class LogBuffer {
|
|
20
|
+
lines = [];
|
|
21
|
+
partial = "";
|
|
22
|
+
append(data, at = Date.now()) {
|
|
23
|
+
const text = this.partial + data.replace(ANSI, "").replace(/\r\n?/g, "\n");
|
|
24
|
+
const parts = text.split("\n");
|
|
25
|
+
this.partial = parts.pop().slice(-MAX_LINE);
|
|
26
|
+
for (const raw of parts) {
|
|
27
|
+
if (!raw.trim())
|
|
28
|
+
continue;
|
|
29
|
+
const line = raw.slice(0, MAX_LINE);
|
|
30
|
+
const previous = this.lines.at(-1);
|
|
31
|
+
const level = CONTINUATION.test(line) && previous && previous.level !== "info" ? previous.level : LEVELS.find((row) => row.pattern.test(line))?.level ?? "info";
|
|
32
|
+
this.lines.push({ at, text: line, level });
|
|
33
|
+
}
|
|
34
|
+
if (this.lines.length > MAX_LINES)
|
|
35
|
+
this.lines = this.lines.slice(-MAX_LINES);
|
|
36
|
+
}
|
|
37
|
+
/** Lines since `since`, with secrets taken out: they're shown, and may be sent to an agent. */
|
|
38
|
+
read(since = 0) {
|
|
39
|
+
return this.lines.filter((line) => line.at > since).map((line) => ({ ...line, text: redactSecrets(line.text) }));
|
|
40
|
+
}
|
|
41
|
+
errorsBetween(from, to = Infinity) {
|
|
42
|
+
return this.read(from).filter((line) => line.level === "error" && line.at <= to);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** "Your last action" per app: what the person (or the agent) just did, so the
|
|
46
|
+
* log can say what came of it. */
|
|
47
|
+
export class LogMarks {
|
|
48
|
+
marks = new Map();
|
|
49
|
+
/** `merge`: traffic from the preview, where a page and the requests it makes
|
|
50
|
+
* in the same moment are one action. Something run on purpose always counts. */
|
|
51
|
+
add(appId, label, { at = Date.now(), merge = false } = {}) {
|
|
52
|
+
const list = this.marks.get(appId) ?? [];
|
|
53
|
+
if (merge && list.at(-1) && at - list.at(-1).at < 1000)
|
|
54
|
+
return;
|
|
55
|
+
list.push({ at, label: label.slice(0, 200) });
|
|
56
|
+
this.marks.set(appId, list.slice(-MAX_MARKS));
|
|
57
|
+
}
|
|
58
|
+
read(appId) { return [...this.marks.get(appId) ?? []]; }
|
|
59
|
+
forget(appId) { this.marks.delete(appId); }
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
const MAX_ROWS = 1000;
|
|
4
|
+
const text = (value) => value === null || value === undefined ? "null" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
5
|
+
/** A delimited line, with quotes ("a ""quoted"" value") as CSV writes them. */
|
|
6
|
+
function splitLine(line, delimiter) {
|
|
7
|
+
const cells = [];
|
|
8
|
+
let cell = "", quoted = false;
|
|
9
|
+
for (let i = 0; i < line.length; i++) {
|
|
10
|
+
const char = line[i];
|
|
11
|
+
if (quoted) {
|
|
12
|
+
if (char === "\"" && line[i + 1] === "\"") {
|
|
13
|
+
cell += "\"";
|
|
14
|
+
i++;
|
|
15
|
+
}
|
|
16
|
+
else if (char === "\"")
|
|
17
|
+
quoted = false;
|
|
18
|
+
else
|
|
19
|
+
cell += char;
|
|
20
|
+
}
|
|
21
|
+
else if (char === "\"" && !cell)
|
|
22
|
+
quoted = true;
|
|
23
|
+
else if (char === delimiter) {
|
|
24
|
+
cells.push(cell);
|
|
25
|
+
cell = "";
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
cell += char;
|
|
29
|
+
}
|
|
30
|
+
return [...cells, cell];
|
|
31
|
+
}
|
|
32
|
+
function delimited(output, delimiter) {
|
|
33
|
+
const lines = output.split(/\r?\n/).filter((line) => line.length);
|
|
34
|
+
if (!lines.length || !lines[0].includes(delimiter))
|
|
35
|
+
return undefined;
|
|
36
|
+
const columns = splitLine(lines[0], delimiter);
|
|
37
|
+
return { columns, rows: lines.slice(1, MAX_ROWS + 1).map((line) => Object.fromEntries(splitLine(line, delimiter).map((cell, i) => [columns[i] ?? `column ${i + 1}`, cell]))) };
|
|
38
|
+
}
|
|
39
|
+
const FORMATS = [
|
|
40
|
+
{ name: "JSON", read: (output) => {
|
|
41
|
+
let value;
|
|
42
|
+
try {
|
|
43
|
+
value = JSON.parse(output);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(value))
|
|
49
|
+
return undefined;
|
|
50
|
+
const objects = value.slice(0, MAX_ROWS).filter((row) => !!row && typeof row === "object" && !Array.isArray(row));
|
|
51
|
+
const columns = [...new Set(objects.flatMap((row) => Object.keys(row)))];
|
|
52
|
+
return { columns, rows: objects.map((row) => Object.fromEntries(columns.map((column) => [column, text(row[column])]))) };
|
|
53
|
+
} },
|
|
54
|
+
{ name: "TSV", read: (output) => delimited(output, "\t") },
|
|
55
|
+
{ name: "CSV", read: (output) => delimited(output, ",") },
|
|
56
|
+
// One column, no delimiter: still a table.
|
|
57
|
+
{ name: "lines", read: (output) => {
|
|
58
|
+
const lines = output.split(/\r?\n/).filter((line) => line.length);
|
|
59
|
+
if (!lines.length)
|
|
60
|
+
return undefined;
|
|
61
|
+
return { columns: [lines[0]], rows: lines.slice(1, MAX_ROWS + 1).map((line) => ({ [lines[0]]: line })) };
|
|
62
|
+
} },
|
|
63
|
+
];
|
|
64
|
+
export function parseRows(output) {
|
|
65
|
+
const trimmed = output.trim();
|
|
66
|
+
if (!trimmed)
|
|
67
|
+
return { columns: [], rows: [] };
|
|
68
|
+
for (const format of FORMATS) {
|
|
69
|
+
const rows = format.read(trimmed);
|
|
70
|
+
if (rows)
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
return { columns: [], rows: [] };
|
|
74
|
+
}
|
|
75
|
+
/** A saved query's file: the query, and its `-- title:` and `-- key:` comments. */
|
|
76
|
+
export function readQueryFile(text) {
|
|
77
|
+
const tag = (name) => new RegExp(`^\\s*--\\s*${name}\\s*:\\s*(.+)$`, "im").exec(text)?.[1]?.trim();
|
|
78
|
+
return { sql: text.trim(), ...(tag("title") ? { title: tag("title") } : {}), ...(tag("key") ? { key: tag("key") } : {}) };
|
|
79
|
+
}
|
package/dist/apps/gateway.js
CHANGED
|
@@ -23,6 +23,7 @@ const REVISION_PATH = "/__bivy/revision";
|
|
|
23
23
|
const INSPECTOR_PATH = "/__bivy/inspector.js";
|
|
24
24
|
const COMPARE_PATH = "/__bivy/compare";
|
|
25
25
|
const NOTES_PATH = "/__bivy/notes";
|
|
26
|
+
const SERVER_ERRORS_PATH = "/__bivy/server-errors";
|
|
26
27
|
const SCENARIOS_PATH = "/__bivy/scenarios";
|
|
27
28
|
const SCENARIO_PATH = "/__bivy/scenario";
|
|
28
29
|
const scenarioKind = (entry) => entry.target.kind === "display" ? "desktop" : "web";
|
|
@@ -518,6 +519,10 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
518
519
|
this.compare(req, res, entry);
|
|
519
520
|
return;
|
|
520
521
|
}
|
|
522
|
+
if (req.url.startsWith(SERVER_ERRORS_PATH) && req.method === "GET") {
|
|
523
|
+
this.serverErrors(req, res, entry, session);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
521
526
|
if (req.url === SCENARIOS_PATH && req.method === "GET") {
|
|
522
527
|
this.scenarios(res, entry, session);
|
|
523
528
|
return;
|
|
@@ -545,6 +550,15 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
545
550
|
if (rule && await simulate(req, res, rule, responseHeaders({}, this.ancestors(id))))
|
|
546
551
|
return;
|
|
547
552
|
const inspect = isPageLoad(req);
|
|
553
|
+
// What the person does in the preview marks the app's logs ("your last
|
|
554
|
+
// action"): opening a page, or sending something. Assets don't count.
|
|
555
|
+
const pathOnly = req.url.split("?")[0].slice(0, 200);
|
|
556
|
+
if (inspect) {
|
|
557
|
+
entry.loadedAt = Date.now();
|
|
558
|
+
this.registry.emit("action", id, `Opened ${pathOnly}`);
|
|
559
|
+
}
|
|
560
|
+
else if (!["GET", "HEAD", "OPTIONS"].includes(req.method ?? ""))
|
|
561
|
+
this.registry.emit("action", id, `${req.method} ${pathOnly} from the preview`);
|
|
548
562
|
// Remember the framed page so a turn reload lands where the user was.
|
|
549
563
|
if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
|
|
550
564
|
entry.lastPath = req.url;
|
|
@@ -885,6 +899,22 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
|
|
|
885
899
|
res.writeHead(200, { "content-type": "application/json" });
|
|
886
900
|
res.end(JSON.stringify({ screenshot: Boolean(shot) }));
|
|
887
901
|
}
|
|
902
|
+
/** Server errors for the shell's Console: since the page loaded ("page"),
|
|
903
|
+
* or since the last answer's `now`. The owner's only: a server's log is not
|
|
904
|
+
* for people with a shared link. */
|
|
905
|
+
serverErrors(req, res, entry, session) {
|
|
906
|
+
const cors = { "access-control-allow-origin": this.shellOrigin(entry.view.id), "access-control-allow-credentials": "true", vary: "Origin" };
|
|
907
|
+
if (session.reviewer) {
|
|
908
|
+
res.writeHead(403, cors);
|
|
909
|
+
res.end();
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const raw = new URL(req.url, "http://gateway").searchParams.get("since");
|
|
913
|
+
const since = raw === "page" || raw === null ? (entry.loadedAt ?? Date.now()) - 1 : Number(raw);
|
|
914
|
+
const found = Number.isFinite(since) ? this.registry.serverErrors?.(entry.view.id, since) : undefined;
|
|
915
|
+
res.writeHead(200, { ...cors, "content-type": "application/json", "cache-control": "no-store" });
|
|
916
|
+
res.end(JSON.stringify(found ?? { now: Date.now(), lines: [] }));
|
|
917
|
+
}
|
|
888
918
|
/** The view's scenarios for the shell's switcher, and the one it's in: this
|
|
889
919
|
* browser's for a web page, the app's for a desktop app. */
|
|
890
920
|
scenarios(res, entry, session) {
|
|
@@ -132,7 +132,7 @@ body.badged #dock:not([data-edge="top"]) { bottom:calc(var(--space-2) + var(--sp
|
|
|
132
132
|
<div id="dock">
|
|
133
133
|
<section class="panel" id="console" hidden aria-labelledby="console-title">
|
|
134
134
|
<h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
|
|
135
|
-
<p class="muted" id="console-empty">No errors or warnings since this page loaded.</p>
|
|
135
|
+
<p class="muted" id="console-empty">No errors or warnings since this page loaded, from the page or its server.</p>
|
|
136
136
|
<ul id="entries"></ul>
|
|
137
137
|
<div class="panel-actions"><button class="btn sm" id="send-errors" disabled>Send to agent…</button></div>
|
|
138
138
|
</section>
|
|
@@ -248,6 +248,7 @@ function show(data,launch){
|
|
|
248
248
|
// Put them back where they were before an update they asked for.
|
|
249
249
|
if(restore){const at=restore;restore=null;frame.contentWindow?.postMessage(Object.assign({type:'bivy:restore'},at),metadata.origin);}
|
|
250
250
|
if(!watching){watching=true;latest=-1;shown=null;watch();firstHint();}else if(wake)wake();
|
|
251
|
+
if(!serverPolling)void pollServer();
|
|
251
252
|
};
|
|
252
253
|
// A Bivy client framing the shell says whether it can take dictation.
|
|
253
254
|
if(embedded&&data.returnTo)toBivy({type:'hello'});
|
|
@@ -597,18 +598,37 @@ function failScenario(s,message){
|
|
|
597
598
|
$('scn-stay').onclick=()=>{$('scn-fail').hidden=true;scnBtn.hidden?more.focus():scnBtn.focus();};
|
|
598
599
|
// Console
|
|
599
600
|
let entries=[];
|
|
600
|
-
function resetConsole(){entries=[];renderConsole();}
|
|
601
|
+
function resetConsole(){entries=[];serverSince='page';renderConsole();}
|
|
602
|
+
// The server's errors join the page's: what the app's server logged since this
|
|
603
|
+
// page loaded (Bivy follows the output of servers it runs). The owner's only.
|
|
604
|
+
let serverSince='page',serverPolling=false;
|
|
605
|
+
async function pollServer(){
|
|
606
|
+
serverPolling=true;
|
|
607
|
+
if(metadata&&!metadata.reviewer&&metadata.inspect!==false){
|
|
608
|
+
try{
|
|
609
|
+
const r=await fetch(metadata.origin+'/__bivy/server-errors?since='+serverSince,{credentials:'include',cache:'no-store'});
|
|
610
|
+
if(r.ok){
|
|
611
|
+
const d=await r.json();
|
|
612
|
+
for(const line of (Array.isArray(d.lines)?d.lines:[]).slice(-20))entries.push({level:'error',source:'server',text:String(line.text).slice(0,500)});
|
|
613
|
+
if(entries.length>50)entries.splice(0,entries.length-50);
|
|
614
|
+
if(d.lines?.length)renderConsole();
|
|
615
|
+
if(Number.isFinite(d.now))serverSince=String(d.now);
|
|
616
|
+
}
|
|
617
|
+
}catch{}
|
|
618
|
+
}
|
|
619
|
+
setTimeout(()=>void pollServer(),document.hidden?10000:3000);
|
|
620
|
+
}
|
|
601
621
|
function renderConsole(){
|
|
602
622
|
const errors=entries.filter(e=>e.level==='error').length,count=$('error-count');
|
|
603
623
|
count.hidden=!errors;count.textContent=String(errors);
|
|
604
624
|
// The number is shown once, on the pill, so it is visible without opening it.
|
|
605
625
|
$('more').setAttribute('aria-label',errors?'Preview options, '+errors+' error'+(errors===1?'':'s'):'Preview options');
|
|
606
626
|
$('console-empty').hidden=entries.length>0;$('send-errors').disabled=!entries.length;
|
|
607
|
-
$('entries').replaceChildren(...entries.map(e=>{const li=document.createElement('li');const tag=document.createElement('span');tag.className='badge';tag.dataset.tone=e.level==='error'?'danger':'warn';tag.textContent=e.level;const text=document.createElement('span');text.textContent=e.text;li.append(tag,text);return li;}));
|
|
627
|
+
$('entries').replaceChildren(...entries.map(e=>{const li=document.createElement('li');const tag=document.createElement('span');tag.className='badge';tag.dataset.tone=e.level==='error'?'danger':'warn';tag.textContent=e.source==='server'?'server':e.level;const text=document.createElement('span');text.textContent=e.text;li.append(tag,text);return li;}));
|
|
608
628
|
}
|
|
609
629
|
$('errors').onclick=()=>panels($('console').hidden?'console':null);
|
|
610
630
|
$('clear').onclick=resetConsole;
|
|
611
|
-
$('send-errors').onclick=()=>draft('Console output in the app preview "'+metadata.name+'" (page '+currentPath+'):\\n'+entries.slice(-20).map(e=>'- '+e.level+': '+e.text).join('\\n'));
|
|
631
|
+
$('send-errors').onclick=()=>draft('Console output in the app preview "'+metadata.name+'" (page '+currentPath+'):\\n'+entries.slice(-20).map(e=>'- '+(e.source==='server'?'server error':e.level)+': '+e.text).join('\\n'));
|
|
612
632
|
// Marking: one gesture, two results. Pointing at an element and circling an
|
|
613
633
|
// area were two modes with a button each; they are one mode now, because they
|
|
614
634
|
// are one intention — "this, here".
|
package/dist/apps/registry.js
CHANGED
|
@@ -98,9 +98,60 @@ function command(spec, workspace, label) {
|
|
|
98
98
|
* preview addresses survive restarts. Views whose server Bivy doesn't own —
|
|
99
99
|
* a service without `start` — are not restored: a reused local port could
|
|
100
100
|
* belong to anything by then. Detection offers them again. */
|
|
101
|
+
/** A folder for a backend view's files: inside the workspace, never above it. */
|
|
102
|
+
function inside(workspace, dir, fallback) {
|
|
103
|
+
const relative = dir === undefined ? fallback : dir;
|
|
104
|
+
if (typeof relative !== "string" || !relative || relative.length > 300 || path.isAbsolute(relative))
|
|
105
|
+
throw new Error("A backend view's dir is a folder inside the workspace, like .bivy/requests.");
|
|
106
|
+
const resolved = path.resolve(workspace, relative);
|
|
107
|
+
const from = path.relative(path.resolve(workspace), resolved);
|
|
108
|
+
if (from === ".." || from.startsWith(`..${path.sep}`))
|
|
109
|
+
throw new Error("A backend view's dir must be inside the workspace.");
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
function backendTarget(spec, workspace) {
|
|
113
|
+
if (spec.kind === "requests") {
|
|
114
|
+
const base = spec.base;
|
|
115
|
+
if (base !== undefined) {
|
|
116
|
+
if (typeof base.url === "string") {
|
|
117
|
+
const url = new URL(base.url);
|
|
118
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
119
|
+
throw new Error("A requests view's base.url is an http(s) address without credentials.");
|
|
120
|
+
return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests"), base: { url: url.href.replace(/\/$/, "") } };
|
|
121
|
+
}
|
|
122
|
+
if (typeof base.view !== "string" || !base.view)
|
|
123
|
+
throw new Error("A requests view's base is {\"url\": …} or {\"view\": \"<web view name>\"}.");
|
|
124
|
+
return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests"), base: { view: base.view } };
|
|
125
|
+
}
|
|
126
|
+
return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests") };
|
|
127
|
+
}
|
|
128
|
+
if (spec.kind === "data")
|
|
129
|
+
return { kind: "data", dir: inside(workspace, spec.dir, ".bivy/queries"), ...command(spec, workspace, "Data view") };
|
|
130
|
+
const source = spec.source;
|
|
131
|
+
if (!source)
|
|
132
|
+
return { kind: "logs", source: {} };
|
|
133
|
+
if (typeof source.view === "string")
|
|
134
|
+
return { kind: "logs", source: { view: source.view } };
|
|
135
|
+
// A file is followed the way a person would: tail -F, from the last lines.
|
|
136
|
+
if (typeof source.file === "string")
|
|
137
|
+
return { kind: "logs", source: { command: "tail", args: ["-n", "200", "-F", path.relative(workspace, inside(workspace, source.file, ""))], workspace: fs.realpathSync(workspace) } };
|
|
138
|
+
return { kind: "logs", source: command(source, workspace, "Logs view") };
|
|
139
|
+
}
|
|
140
|
+
/** Where a backend view reads from, in words, for its row in the Apps sheet. */
|
|
141
|
+
function backendDetail(target) {
|
|
142
|
+
const line = (spec) => [spec.command, ...spec.args].join(" ").slice(0, 120);
|
|
143
|
+
if (target.kind === "requests")
|
|
144
|
+
return `${path.basename(target.dir)} · ${target.base && "url" in target.base ? target.base.url : target.base ? target.base.view : "the app's server"}`;
|
|
145
|
+
if (target.kind === "data")
|
|
146
|
+
return line(target);
|
|
147
|
+
return "command" in target.source ? line(target.source) : target.source.view ? `${target.source.view} output` : "the app's server output";
|
|
148
|
+
}
|
|
101
149
|
export class AppRegistry extends EventEmitter {
|
|
102
150
|
reservedPorts;
|
|
103
151
|
file;
|
|
152
|
+
/** Errors a web view's server logged since `since` (set by the backend
|
|
153
|
+
* views, which follow server output; asked by the gateway for the Console). */
|
|
154
|
+
serverErrors;
|
|
104
155
|
apps = new Map();
|
|
105
156
|
views = new Map();
|
|
106
157
|
persisted = new Map();
|
|
@@ -198,8 +249,12 @@ export class AppRegistry extends EventEmitter {
|
|
|
198
249
|
target = { kind: "display", ...command(spec, workspace, "Display view"), restartOnChange: spec.restartOnChange === true };
|
|
199
250
|
view = { ...base, kind: "web", source: "display", managed: true };
|
|
200
251
|
}
|
|
252
|
+
else if (spec.kind === "requests" || spec.kind === "data" || spec.kind === "logs") {
|
|
253
|
+
target = backendTarget(spec, workspace);
|
|
254
|
+
view = { ...base, kind: "backend", backend: spec.kind, detail: backendDetail(target) };
|
|
255
|
+
}
|
|
201
256
|
else
|
|
202
|
-
throw new Error("Unsupported app view. Supported views: web, terminal and
|
|
257
|
+
throw new Error("Unsupported app view. Supported views: web, terminal, display, requests, data and logs.");
|
|
203
258
|
app.views.push(view);
|
|
204
259
|
const stored = restore?.notes?.[view.id];
|
|
205
260
|
const notes = view.kind === "web" && Array.isArray(stored) ? stored.filter(isNote).slice(-MAX_NOTES) : [];
|
package/dist/apps/service.js
CHANGED
|
@@ -13,6 +13,7 @@ import { captureFrame, encodePng, sendInput } from "./rfb.js";
|
|
|
13
13
|
import { inputEvents, readAction } from "./input.js";
|
|
14
14
|
import { pressMenu, readMenuPath, readMenus } from "./menu.js";
|
|
15
15
|
import { pngSize } from "./review.js";
|
|
16
|
+
import { Backend } from "./backend/index.js";
|
|
16
17
|
import { SCENARIO_DIR, loadScenarios, planScenario, scenariosFor, summarize } from "./scenarios.js";
|
|
17
18
|
import { startScenarioProxy } from "./scenario-proxy.js";
|
|
18
19
|
const scenarioKind = (entry) => entry.target.kind === "display" ? "desktop" : "web";
|
|
@@ -71,6 +72,8 @@ export class AppService {
|
|
|
71
72
|
proxies = new Map();
|
|
72
73
|
/** Screenshots in progress, so a card, Compare and a baseline share one browser run. */
|
|
73
74
|
inflight = new Map();
|
|
75
|
+
/** Requests, data and logs views: what a backend change did. */
|
|
76
|
+
backend;
|
|
74
77
|
constructor(registry, gateway, terminals, options = {}) {
|
|
75
78
|
this.registry = registry;
|
|
76
79
|
this.gateway = gateway;
|
|
@@ -83,6 +86,7 @@ export class AppService {
|
|
|
83
86
|
this.reviews = options.reviews ?? NO_REVIEWS;
|
|
84
87
|
this.pinSink = options.pins ?? NO_PINS;
|
|
85
88
|
this.settleMs = options.settleMs ?? COMPARE_SETTLE_MS;
|
|
89
|
+
this.backend = new Backend(registry, terminals);
|
|
86
90
|
registry.desktopScenario = (viewId, scenario) => this.desktopScenario(viewId, scenario);
|
|
87
91
|
}
|
|
88
92
|
/** A session's apps, or every app on this machine when no session is given. */
|
|
@@ -200,11 +204,16 @@ export class AppService {
|
|
|
200
204
|
const views = this.reviewable(sessionId);
|
|
201
205
|
const run = { start: new Map(views.map((entry) => [entry.view.id, entry.revision])), baseline: new Map(), reviews: new Map(), muted: false, ended: false };
|
|
202
206
|
this.runs.set(sessionId, run);
|
|
207
|
+
if (this.backend.views(sessionId).length)
|
|
208
|
+
run.backend = this.backend.baseline(sessionId).catch(() => { });
|
|
209
|
+
// Settles when the backend's "before" is taken (tests wait on it; the server doesn't need to).
|
|
210
|
+
const ready = run.backend ?? Promise.resolve();
|
|
203
211
|
if (!this.screenshots.enabled())
|
|
204
|
-
return;
|
|
212
|
+
return ready;
|
|
205
213
|
for (const entry of views)
|
|
206
214
|
void this.shotOf(entry).then((png) => { if (png && entry.revision === run.start.get(entry.view.id))
|
|
207
215
|
run.baseline.set(entry.view.id, png); });
|
|
216
|
+
return ready;
|
|
208
217
|
}
|
|
209
218
|
/** The run ended (done, or waiting for the user). Views whose revision
|
|
210
219
|
* changed are screenshotted and compared with the page before the run; a
|
|
@@ -216,6 +225,12 @@ export class AppService {
|
|
|
216
225
|
return undefined;
|
|
217
226
|
run.ended = true;
|
|
218
227
|
let latest;
|
|
228
|
+
// Backend views run again; what changed is evidence, on the app's card.
|
|
229
|
+
let evidence = new Map();
|
|
230
|
+
if (run.backend) {
|
|
231
|
+
await run.backend;
|
|
232
|
+
evidence = await this.backend.evidence(sessionId).catch(() => evidence);
|
|
233
|
+
}
|
|
219
234
|
const changed = [...run.start].map(([id, revision]) => ({ entry: this.registry.getView(id), revision })).filter(({ entry, revision }) => entry && entry.revision !== revision);
|
|
220
235
|
if (changed.length && this.screenshots.enabled() && !run.muted)
|
|
221
236
|
await new Promise((r) => setTimeout(r, this.settleMs));
|
|
@@ -238,7 +253,7 @@ export class AppService {
|
|
|
238
253
|
const presented = current && run.reviews.get(entry.app.id) === current.id;
|
|
239
254
|
if (!shouldReview({ trigger: "run", mode, muted: run.muted, revisionChanged: true, change }) && !(presented && after))
|
|
240
255
|
continue;
|
|
241
|
-
latest = this.review(entry, run, { trigger: presented ? current.trigger : "run", note: presented ? current.note : undefined, try: presented ? current.try : undefined, path: entry.lastPath ?? "/", shot: after, before });
|
|
256
|
+
latest = this.review(entry, run, { trigger: presented ? current.trigger : "run", note: presented ? current.note : undefined, try: presented ? current.try : undefined, path: entry.lastPath ?? "/", shot: after, before, evidence: evidence.get(entry.app.id)?.changed ? evidence.get(entry.app.id).rows : undefined });
|
|
242
257
|
}
|
|
243
258
|
// Reviewer notes that arrived since the last hand-over ride on the view's
|
|
244
259
|
// card for this run, or get a card of their own. A count only: the notes
|
|
@@ -263,6 +278,14 @@ export class AppService {
|
|
|
263
278
|
latest ??= card;
|
|
264
279
|
}
|
|
265
280
|
}
|
|
281
|
+
// A backend-only change: the evidence is the card.
|
|
282
|
+
for (const [appId, found] of evidence) {
|
|
283
|
+
if (!found.changed || run.muted || run.reviews.has(appId) || this.registry.reviewMode(appId) === "off")
|
|
284
|
+
continue;
|
|
285
|
+
const entry = this.registry.getView(found.rows[0].viewId);
|
|
286
|
+
if (entry)
|
|
287
|
+
latest = this.review(entry, run, { trigger: "run", path: "/", evidence: found.rows });
|
|
288
|
+
}
|
|
266
289
|
if (!latest) {
|
|
267
290
|
const id = [...run.reviews.values()].at(-1);
|
|
268
291
|
latest = [...this.latest.values()].find((review) => review.id === id);
|
|
@@ -776,6 +799,7 @@ export class AppService {
|
|
|
776
799
|
throw new Error("App was removed while starting.");
|
|
777
800
|
}
|
|
778
801
|
server.termId = termId;
|
|
802
|
+
this.backend.attach(id, termId);
|
|
779
803
|
return termId;
|
|
780
804
|
})
|
|
781
805
|
.finally(() => { server.pending = undefined; });
|
|
@@ -818,6 +842,16 @@ export class AppService {
|
|
|
818
842
|
void server.pending?.then((termId) => this.terminals.close(termId)).catch(() => { });
|
|
819
843
|
this.servers.delete(id);
|
|
820
844
|
}
|
|
845
|
+
/** A backend view of a session, by exact IDs (the app) or by name (`bivy app requests`). */
|
|
846
|
+
backendView(sessionId, kind, input) {
|
|
847
|
+
if (input.appId && input.viewId) {
|
|
848
|
+
const entry = this.registry.requireView(sessionId, input.appId, input.viewId);
|
|
849
|
+
if (entry.target.kind !== kind)
|
|
850
|
+
throw new Error(`That isn't a ${kind} view.`);
|
|
851
|
+
return entry;
|
|
852
|
+
}
|
|
853
|
+
return this.backend.pick(sessionId, kind, input.target);
|
|
854
|
+
}
|
|
821
855
|
share(sessionId, appId, viewId, options = {}) {
|
|
822
856
|
if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
|
|
823
857
|
throw new Error("Only web views have preview links.");
|
|
@@ -882,6 +916,7 @@ export class AppService {
|
|
|
882
916
|
remove(sessionId, appId) {
|
|
883
917
|
const app = this.registry.require(sessionId, appId);
|
|
884
918
|
this.registry.remove(sessionId, appId);
|
|
919
|
+
this.backend.forget(app.views.map((view) => view.id), app.id);
|
|
885
920
|
for (const view of app.views) {
|
|
886
921
|
this.gateway?.revoke(view.id);
|
|
887
922
|
this.stopServer(view.id);
|
package/dist/apps/styles.css
CHANGED
|
@@ -3243,6 +3243,14 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
|
|
|
3243
3243
|
.review-side .seg-btn { min-block-size: 44px; min-inline-size: 72px; }
|
|
3244
3244
|
.review-banner { margin: 0 var(--space-3); }
|
|
3245
3245
|
.review-note { margin: var(--space-3) var(--space-3) 0; padding-inline-start: var(--space-3); border-inline-start: 2px solid var(--line-strong); color: var(--ink); font-size: var(--text-sm); white-space: pre-wrap; }
|
|
3246
|
+
/* Backend evidence on a review card: one line per thing a run changed. */
|
|
3247
|
+
.review-evidence { list-style: none; margin: 0 var(--space-3); padding: 0; border-top: 1px solid var(--line); }
|
|
3248
|
+
.review-evidence-row { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr) auto; align-items: baseline; gap: var(--space-2); inline-size: 100%; min-block-size: var(--space-7); padding: var(--space-2) 0; border: 0; border-bottom: 1px solid var(--line); background: none; color: var(--ink); font: inherit; font-size: var(--text-sm); text-align: start; cursor: pointer; }
|
|
3249
|
+
.review-evidence li:last-child .review-evidence-row { border-bottom: 0; }
|
|
3250
|
+
.review-evidence-row:hover { background: var(--surface-2); }
|
|
3251
|
+
.review-evidence-kind { color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
|
|
3252
|
+
.review-evidence-text { display: flex; flex-wrap: wrap; gap: 0 var(--space-2); min-inline-size: 0; overflow-wrap: anywhere; }
|
|
3253
|
+
.review-evidence-text code { color: var(--muted); font-family: var(--font-mono); font-size: var(--text-xs); }
|
|
3246
3254
|
/* "Try it": scenarios the agent offered, each opening the live preview in that state. */
|
|
3247
3255
|
.review-try { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); margin: var(--space-3) var(--space-3) 0; }
|
|
3248
3256
|
.review-try-label { margin-inline-end: var(--space-1); color: var(--muted); font-size: var(--text-sm); }
|
|
@@ -3278,6 +3286,64 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
|
|
|
3278
3286
|
.apps-note { display: flex; flex-direction: column; gap: 2px; padding-inline-start: var(--space-3); border-inline-start: 2px solid var(--line-strong); }
|
|
3279
3287
|
.apps-note-text { color: var(--ink); font-size: var(--text-sm); }
|
|
3280
3288
|
.apps-notes-actions { display: flex; gap: var(--space-2); }
|
|
3289
|
+
/* Backend views (BackendView.tsx): Requests, Data and Logs. Rows reuse the
|
|
3290
|
+
Apps sheet's density; answers and rows are the app's own text, in mono. */
|
|
3291
|
+
.backend-view { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3) var(--space-4) var(--space-5); }
|
|
3292
|
+
.backend-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2); }
|
|
3293
|
+
.backend-source, .backend-meta { color: var(--muted); font-size: var(--text-xs); overflow-wrap: anywhere; }
|
|
3294
|
+
.backend-meta code, .backend-foot code, .backend-row-sub { font-family: var(--font-mono); }
|
|
3295
|
+
.backend-back { align-self: flex-start; }
|
|
3296
|
+
.backend-group { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
3297
|
+
.backend-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
|
|
3298
|
+
.backend-row { display: flex; align-items: center; gap: var(--space-2); inline-size: 100%; min-block-size: var(--space-7); box-sizing: border-box; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); color: var(--ink); font: inherit; text-align: start; cursor: pointer; }
|
|
3299
|
+
.backend-row:hover { background: var(--surface-2); }
|
|
3300
|
+
.backend-method { flex: none; min-inline-size: 3.25rem; font-family: var(--font-mono); font-size: var(--text-xs); font-weight: var(--weight-bold); color: var(--accent); }
|
|
3301
|
+
.backend-method[data-method="GET"], .backend-method[data-method="HEAD"] { color: var(--ok); }
|
|
3302
|
+
.backend-method[data-method="DELETE"] { color: var(--danger); }
|
|
3303
|
+
.backend-row-text { display: flex; flex-direction: column; flex: 1; min-inline-size: 0; gap: 2px; }
|
|
3304
|
+
.backend-row-name { font-size: var(--text-sm); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
|
3305
|
+
.backend-row-sub { color: var(--muted); font-size: var(--text-xs); overflow-wrap: anywhere; }
|
|
3306
|
+
.backend-foot, .backend-empty { margin: 0; color: var(--muted); font-size: var(--text-xs); }
|
|
3307
|
+
.backend-empty { font-size: var(--text-sm); }
|
|
3308
|
+
.backend-detail-head { display: flex; align-items: flex-start; gap: var(--space-2); }
|
|
3309
|
+
.backend-status { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
|
3310
|
+
.backend-switch { align-self: flex-start; }
|
|
3311
|
+
.backend-body { margin: 0; max-block-size: 50vh; overflow: auto; padding: var(--space-3); border-radius: var(--radius-md); background: var(--surface-2); color: var(--ink); font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
3312
|
+
.backend-request summary { color: var(--muted); font-size: var(--text-sm); cursor: pointer; margin-block-end: var(--space-2); }
|
|
3313
|
+
.backend-changes { list-style: none; margin: 0; padding: 0; border-radius: var(--radius-md); background: var(--surface-2); font-family: var(--font-mono); font-size: var(--text-xs); }
|
|
3314
|
+
.backend-changes li { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-2); padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
|
|
3315
|
+
.backend-changes li:last-child { border-bottom: 0; }
|
|
3316
|
+
.backend-path { color: var(--muted); }
|
|
3317
|
+
.backend-was { color: var(--del); text-decoration: line-through; }
|
|
3318
|
+
.backend-now { color: var(--add); }
|
|
3319
|
+
.backend-gone { color: var(--del); }
|
|
3320
|
+
.backend-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
|
|
3321
|
+
.backend-actions .btn { min-block-size: var(--space-7); }
|
|
3322
|
+
.backend-query { display: flex; flex-direction: column; gap: var(--space-2); padding-block-start: var(--space-3); border-top: 1px solid var(--line); }
|
|
3323
|
+
.backend-query-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-2); }
|
|
3324
|
+
.backend-query-head h3 { margin: 0; font-size: var(--text-base); }
|
|
3325
|
+
.backend-counts { font-family: var(--font-mono); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
|
|
3326
|
+
.backend-counts [data-tone="ok"] { color: var(--ok); } .backend-counts [data-tone="warn"] { color: var(--warn); } .backend-counts [data-tone="danger"] { color: var(--danger); }
|
|
3327
|
+
.backend-rowcard { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-inline-start: 3px solid var(--line-strong); border-radius: var(--radius-md); }
|
|
3328
|
+
.backend-rowcard[data-kind="added"] { border-inline-start-color: var(--ok); }
|
|
3329
|
+
.backend-rowcard[data-kind="changed"] { border-inline-start-color: var(--warn); }
|
|
3330
|
+
.backend-rowcard[data-kind="removed"] { border-inline-start-color: var(--danger); }
|
|
3331
|
+
.backend-fields { display: grid; grid-template-columns: minmax(5rem, max-content) minmax(0, 1fr); gap: 2px var(--space-3); margin: 0; font-size: var(--text-xs); }
|
|
3332
|
+
.backend-fields div { display: contents; }
|
|
3333
|
+
.backend-fields dt { color: var(--muted); }
|
|
3334
|
+
.backend-fields dd { margin: 0; font-family: var(--font-mono); overflow-wrap: anywhere; }
|
|
3335
|
+
.backend-table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-md); }
|
|
3336
|
+
.backend-table { border-collapse: collapse; inline-size: 100%; font-size: var(--text-xs); }
|
|
3337
|
+
.backend-table th, .backend-table td { padding: var(--space-1) var(--space-2); border-bottom: 1px solid var(--line); text-align: start; white-space: nowrap; }
|
|
3338
|
+
.backend-table th { position: sticky; top: 0; background: var(--surface-2); color: var(--muted); font-weight: var(--weight-semibold); }
|
|
3339
|
+
.backend-table td { font-family: var(--font-mono); }
|
|
3340
|
+
.backend-log { list-style: none; margin: 0; padding: var(--space-2) 0; max-block-size: 60vh; overflow: auto; border-radius: var(--radius-md); background: var(--surface-2); font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.6; }
|
|
3341
|
+
.backend-line { padding: 1px var(--space-3); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
3342
|
+
.backend-line[data-level="error"] { background: var(--danger-soft); }
|
|
3343
|
+
.backend-line[data-level="warn"] { color: var(--warn); }
|
|
3344
|
+
.backend-line-time { color: var(--muted); }
|
|
3345
|
+
.backend-mark { display: flex; align-items: center; gap: var(--space-2); margin: var(--space-2) 0; padding: 0 var(--space-3); color: var(--accent); font-family: var(--font-sans); font-weight: var(--weight-semibold); scroll-margin-block-start: var(--space-2); }
|
|
3346
|
+
.backend-mark::before, .backend-mark::after { content: ""; flex: 1; block-size: 1px; background: color-mix(in srgb, var(--accent) 40%, var(--line)); }
|
|
3281
3347
|
.apps-empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); padding: var(--space-6) var(--space-4); color: var(--muted); font-size: var(--text-sm); text-align: center; }
|
|
3282
3348
|
.apps-empty strong { color: var(--ink); font-size: var(--text-base); }
|
|
3283
3349
|
.apps-empty p { margin: 0; max-inline-size: 34ch; }
|
package/dist/apps/types.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
export const REVIEW_CARD_MODES = ["ready", "every", "off"];
|
|
4
|
+
const isEvidence = (row) => {
|
|
5
|
+
const r = row;
|
|
6
|
+
return !!r && typeof r.viewId === "string" && typeof r.view === "string" && ["requests", "data", "logs"].includes(r.backend)
|
|
7
|
+
&& typeof r.summary === "string" && r.summary.length <= 300 && (r.detail === undefined || (typeof r.detail === "string" && r.detail.length <= 300))
|
|
8
|
+
&& ["ok", "warn", "danger", "neutral"].includes(r.tone) && (r.item === undefined || typeof r.item === "string");
|
|
9
|
+
};
|
|
4
10
|
export const APP_REVIEW_BLOCK = "bivy_app_review";
|
|
5
11
|
const isShot = (value) => {
|
|
6
12
|
const shot = value;
|
|
@@ -16,6 +22,7 @@ export function isAppReview(value) {
|
|
|
16
22
|
&& (review.shot === undefined || isShot(review.shot)) && (review.before === undefined || isShot(review.before))
|
|
17
23
|
&& (review.note === undefined || typeof review.note === "string")
|
|
18
24
|
&& (review.notes === undefined || (Number.isInteger(review.notes) && review.notes >= 0))
|
|
25
|
+
&& (review.evidence === undefined || (Array.isArray(review.evidence) && review.evidence.length <= 12 && review.evidence.every(isEvidence)))
|
|
19
26
|
&& (review.try === undefined || (Array.isArray(review.try) && review.try.length <= 6 && review.try.every((item) => !!item && typeof item.id === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(item.id) && typeof item.name === "string" && item.name.length <= 80)));
|
|
20
27
|
}
|
|
21
28
|
export const APP_PIN_STATES = ["open", "changed", "gone", "done"];
|
|
@@ -9,6 +9,7 @@ export function createAppCommands(service, workspaceFor, published = () => { },
|
|
|
9
9
|
throw new Error("Open the session on this machine before publishing an app.");
|
|
10
10
|
return workspace;
|
|
11
11
|
};
|
|
12
|
+
const pick = (msg) => ({ appId: typeof msg.appId === "string" ? msg.appId : undefined, viewId: typeof msg.viewId === "string" ? msg.viewId : undefined, target: typeof msg.target === "string" ? msg.target : undefined });
|
|
12
13
|
const operations = {
|
|
13
14
|
"apps.list": (msg) => service.list(typeof msg.sessionId === "string" ? msg.sessionId : undefined),
|
|
14
15
|
"apps.publish": (msg) => {
|
|
@@ -43,6 +44,15 @@ export function createAppCommands(service, workspaceFor, published = () => { },
|
|
|
43
44
|
"apps.agentNotes": (msg) => service.setAgentNotes(String(msg.sessionId), String(msg.appId), msg.enabled === true),
|
|
44
45
|
"apps.notes": (msg) => service.notes(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined, since: typeof msg.since === "number" ? msg.since : undefined }),
|
|
45
46
|
"apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
|
|
47
|
+
// Backend views. The app passes exact IDs; `bivy app requests|data|logs` a name, or nothing.
|
|
48
|
+
"apps.requests": (msg) => service.backend.requests(service.backendView(String(msg.sessionId), "requests", pick(msg))),
|
|
49
|
+
"apps.request": (msg) => service.backend.detail(service.backendView(String(msg.sessionId), "requests", pick(msg)), String(msg.id)),
|
|
50
|
+
"apps.runRequest": (msg) => {
|
|
51
|
+
const entry = service.backendView(String(msg.sessionId), "requests", pick(msg));
|
|
52
|
+
return typeof msg.id === "string" ? service.backend.run(entry, msg.id) : service.backend.runAuto(entry, "last", "Ran all requests");
|
|
53
|
+
},
|
|
54
|
+
"apps.data": (msg) => service.backend.data(service.backendView(String(msg.sessionId), "data", pick(msg)), msg.run === true),
|
|
55
|
+
"apps.serverLog": (msg) => service.backend.log(service.backendView(String(msg.sessionId), "logs", pick(msg)), typeof msg.since === "number" ? msg.since : 0),
|
|
46
56
|
// The app UI passes exact IDs; `bivy app share` passes an app and/or view by ID or name.
|
|
47
57
|
"apps.share": (msg) => {
|
|
48
58
|
const options = { duration: msg.duration, controls: msg.controls !== false };
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Direct-mode adapters for operations whose canonical implementation is the
|
|
4
4
|
* client command registry. Relay and HTTP now differ only in framing. */
|
|
5
5
|
export const CLIENT_COMMAND_ROUTES = [
|
|
6
|
-
...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "scenarios", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "unshare", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
6
|
+
...["list", "publish", "offers", "adopt", "open", "logs", "requests", "request", "runRequest", "data", "serverLog", "shot", "input", "menu", "present", "scenarios", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "unshare", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
|
|
7
7
|
{ method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
|
|
8
8
|
{ method: "post", path: "/api/access/get", kind: "access.get" },
|
|
9
9
|
{ method: "post", path: "/api/tailnet/machines", kind: "tailnet.machines" },
|
|
@@ -4,6 +4,8 @@ import { Type } from "typebox";
|
|
|
4
4
|
import { SHARE_DURATIONS } from "../apps/types.js";
|
|
5
5
|
const request = { requestId: Type.Optional(Type.String()) };
|
|
6
6
|
const session = { sessionId: Type.String() };
|
|
7
|
+
/** A backend view by exact IDs (the app), or by app or view name (`bivy app requests`). */
|
|
8
|
+
const backendView = { appId: Type.Optional(Type.String({ maxLength: 200 })), viewId: Type.Optional(Type.String({ maxLength: 200 })), target: Type.Optional(Type.String({ maxLength: 200 })) };
|
|
7
9
|
const optionalSession = { sessionId: Type.Optional(Type.String()) };
|
|
8
10
|
/** Declarative validation at the client-command boundary. A command absent from
|
|
9
11
|
* this table remains migration-compatible; adding validation is one data row. */
|
|
@@ -36,6 +38,11 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
36
38
|
"apps.agentNotes": Type.Object({ ...request, ...session, appId: Type.String(), enabled: Type.Boolean() }),
|
|
37
39
|
"apps.notes": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), view: Type.Optional(Type.String({ maxLength: 200 })), since: Type.Optional(Type.Number()) }),
|
|
38
40
|
"apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
|
41
|
+
"apps.requests": Type.Object({ ...request, ...session, ...backendView }),
|
|
42
|
+
"apps.request": Type.Object({ ...request, ...session, ...backendView, id: Type.String({ maxLength: 200 }) }),
|
|
43
|
+
"apps.runRequest": Type.Object({ ...request, ...session, ...backendView, id: Type.Optional(Type.String({ maxLength: 200 })) }),
|
|
44
|
+
"apps.data": Type.Object({ ...request, ...session, ...backendView, run: Type.Optional(Type.Boolean()) }),
|
|
45
|
+
"apps.serverLog": Type.Object({ ...request, ...session, ...backendView, since: Type.Optional(Type.Number()) }),
|
|
39
46
|
"apps.share": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), viewId: Type.Optional(Type.String()), view: Type.Optional(Type.String({ maxLength: 200 })),
|
|
40
47
|
duration: Type.Optional(Type.Union(SHARE_DURATIONS.map((row) => Type.Literal(row.id)))), controls: Type.Optional(Type.Boolean()) }),
|
|
41
48
|
"apps.unshare": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
|
package/dist/server.js
CHANGED
|
@@ -1196,7 +1196,7 @@ function harnessDirFor(record) {
|
|
|
1196
1196
|
* waits on git. Never throws into the prompt path. */
|
|
1197
1197
|
function harnessBeginTurn(record) {
|
|
1198
1198
|
// A run starts: review cards measure what the app looked like before it.
|
|
1199
|
-
appService.runStarted(record.id);
|
|
1199
|
+
void appService.runStarted(record.id);
|
|
1200
1200
|
record.runReview = undefined;
|
|
1201
1201
|
const dir = harnessDirFor(record);
|
|
1202
1202
|
record.harnessTurnReady = undefined;
|
|
@@ -2278,6 +2278,7 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
|
|
|
2278
2278
|
},
|
|
2279
2279
|
has: (id) => terminals.has(id),
|
|
2280
2280
|
close: (id) => { terminals.close(id); },
|
|
2281
|
+
tap: (id, onData) => terminals.tap(id, onData) ?? undefined,
|
|
2281
2282
|
}, {
|
|
2282
2283
|
scan: async (workspace) => notAgentServers(await scanListeners(workspace)),
|
|
2283
2284
|
scanMachine: async (root) => notAgentServers(await scanMachineListeners(root)),
|