@intentius/behold 0.20.0 → 0.22.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/README.md +53 -0
- package/dist/cli.js +301 -65
- package/package.json +1 -1
- package/web/app.js +384 -9
- package/web/embed.js +78 -0
- package/web/embed.test.js +51 -0
- package/web/index.html +20 -0
- package/web/operator.js +14 -1
- package/web/panel.js +11 -3
- package/web/run-playhead.js +13 -1
- package/web/view-url.js +151 -0
- package/web/view-url.test.js +109 -0
- package/web/workspace-gates.js +44 -0
- package/web/workspace-gates.test.js +34 -0
package/web/index.html
CHANGED
|
@@ -132,6 +132,16 @@
|
|
|
132
132
|
either for the full reason (native SVG <title>). */
|
|
133
133
|
#graph g[data-edge-via="data dependency"] .pin-edge-line { stroke-dasharray: 6 5; }
|
|
134
134
|
#graph g[data-edge-from][data-edge-via] { cursor: help; }
|
|
135
|
+
/* #477: a declared workspace's waiting gates, over the top of the graph
|
|
136
|
+
pane (same grid cell, so #graph's re-renders never take it with them). */
|
|
137
|
+
#ws-gates { grid-row: 1; grid-column: 1; align-self: start; justify-self: center; z-index: 3; margin-top: 8px;
|
|
138
|
+
max-width: min(560px, 70%); display: flex; flex-direction: column; gap: 6px; }
|
|
139
|
+
#ws-gates[hidden] { display: none; }
|
|
140
|
+
#ws-gates .ws-gate { background: var(--panel); border: 1px solid var(--warn, var(--rule)); border-radius: var(--r-ctl);
|
|
141
|
+
padding: 6px 10px; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
|
|
142
|
+
#ws-gates .ws-gate-title { font-weight: 600; }
|
|
143
|
+
#ws-gates .ws-gate-facts, #ws-gates .ws-gate-note { color: var(--muted); font-size: var(--t-caption); }
|
|
144
|
+
#ws-gates .ws-gate button { margin-top: 4px; }
|
|
135
145
|
/* Fit reset + a zoom hint, floating top-right of the graph pane. */
|
|
136
146
|
#zoom-toggle { position: absolute; top: 8px; right: 8px; z-index: 2; background: var(--panel);
|
|
137
147
|
color: var(--muted); border: 1px solid var(--rule); border-radius: var(--r-ctl); padding: 4px 10px;
|
|
@@ -279,6 +289,15 @@
|
|
|
279
289
|
color: var(--muted); font: var(--t-caption)/1 var(--font-ui); padding: 6px 0; cursor: pointer; }
|
|
280
290
|
#panel-tabs button[data-tab]:hover { color: var(--fg); }
|
|
281
291
|
#panel-tabs button[data-tab].active { color: var(--fg); background: var(--active); font-weight: 600; }
|
|
292
|
+
/* #476: framed in a host pane. Deploying and the theme are the host's
|
|
293
|
+
business; the panel and the inspect pane start folded (app.js). */
|
|
294
|
+
.embed #panel-tabs button[data-tab="deploy"], .embed #panel-body section[data-tab="deploy"],
|
|
295
|
+
.embed #panel-body section[data-tab="view"] > h3:has(+ #panel-theme), .embed #panel-theme { display: none !important; }
|
|
296
|
+
/* Folded in a frame, the panel is a small pill (⌘K and the expand
|
|
297
|
+
chevron) in the corner, so the graph has the pane to itself. */
|
|
298
|
+
.embed #panel.collapsed #panel-tabs button[data-tab] { display: none; }
|
|
299
|
+
.embed #panel.collapsed { width: auto; }
|
|
300
|
+
.embed #panel.collapsed #panel-tabs { gap: 4px; padding: 2px 4px; }
|
|
282
301
|
#panel-tabs #hintk { flex: none; align-self: center; padding: 2px 6px; margin-left: 2px; }
|
|
283
302
|
#panel-collapse { flex: none; align-self: center; background: none; border: none; color: var(--muted);
|
|
284
303
|
font-size: var(--t-body); cursor: pointer; line-height: 1; padding: 2px 4px; }
|
|
@@ -585,6 +604,7 @@
|
|
|
585
604
|
</div>
|
|
586
605
|
<div id="app">
|
|
587
606
|
<div id="graph"></div>
|
|
607
|
+
<div id="ws-gates" hidden></div>
|
|
588
608
|
<aside id="inspect">
|
|
589
609
|
<div id="inspect-resize" title="Drag to resize"></div>
|
|
590
610
|
<button id="inspect-collapse" title="Collapse panel">›</button>
|
package/web/operator.js
CHANGED
|
@@ -26,6 +26,13 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { waitingFor } from "./run-playhead.js";
|
|
28
28
|
|
|
29
|
+
// #477: why a gate card has no approve button.
|
|
30
|
+
const NO_APPROVE = {
|
|
31
|
+
host: "approve in the host that framed this graph",
|
|
32
|
+
preview: "approving is off in preview",
|
|
33
|
+
static: "a snapshot: approve where behold runs",
|
|
34
|
+
};
|
|
35
|
+
|
|
29
36
|
/** How a lease reads. `expired` is its own tone, not a shade of `free`: a lapsed
|
|
30
37
|
* lease means the holder stopped renewing and the next round reclaims it, which
|
|
31
38
|
* is a different statement from "nobody has ever held it". */
|
|
@@ -459,7 +466,13 @@ export function renderConvergeGateCard(gate, actions = {}) {
|
|
|
459
466
|
if (at) box.appendChild(at);
|
|
460
467
|
|
|
461
468
|
const command = (gate.approve && gate.approve.command) || `chant approve ${gate.op} ${gate.gate}`;
|
|
462
|
-
|
|
469
|
+
// #477: framed in a host, the host approves; the gate is drawn, not offered.
|
|
470
|
+
if (!actions.approve) {
|
|
471
|
+
box.appendChild(el("div", "run-gate-meta", NO_APPROVE[actions.why] || NO_APPROVE.host));
|
|
472
|
+
return box;
|
|
473
|
+
}
|
|
474
|
+
// Standalone, chant records whoever runs behold. Said before the click.
|
|
475
|
+
const approve = el("button", "approve", actions.approver ? `Record approval as ${actions.approver}` : "Record approval");
|
|
463
476
|
approve.title =
|
|
464
477
|
`${(gate.approve && gate.approve.method) || "POST"} ${(gate.approve && gate.approve.path) || ""} — ` +
|
|
465
478
|
`${command}. ${gate.semantics || APPROVE_SEMANTICS} behold never approves on its own.`;
|
package/web/panel.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// browser JS, no build step, themed entirely by the shared CSS vars — same
|
|
7
7
|
// rules as app.js.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// #476: a framed behold keeps its own panel state, folded to start with, so a
|
|
10
|
+
// host pane and a full window never fight over one saved position.
|
|
11
|
+
let STORE_KEY = "behold.panel";
|
|
10
12
|
const MARGIN = 12; // gap kept between a snapped panel and the viewport edge
|
|
11
13
|
const SNAP = 32; // released within this many px of an edge → dock to it
|
|
12
14
|
|
|
@@ -168,9 +170,14 @@ export function isPanelCollapsed() {
|
|
|
168
170
|
return !!state.collapsed;
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
export function initPanel() {
|
|
173
|
+
export function initPanel({ embed = false } = {}) {
|
|
172
174
|
panel = document.getElementById("panel");
|
|
173
175
|
if (!panel) return;
|
|
176
|
+
if (embed) {
|
|
177
|
+
STORE_KEY = "behold.panel.embed";
|
|
178
|
+
state.collapsed = true;
|
|
179
|
+
state.y = 12;
|
|
180
|
+
}
|
|
174
181
|
state = loadState();
|
|
175
182
|
const bar = document.getElementById("panel-tabs");
|
|
176
183
|
initDrag(bar);
|
|
@@ -182,7 +189,8 @@ export function initPanel() {
|
|
|
182
189
|
for (const b of panel.querySelectorAll("#panel-tabs button[data-tab]")) b.addEventListener("click", () => setPanelTab(b.dataset.tab));
|
|
183
190
|
const known = [...panel.querySelectorAll("#panel-tabs button[data-tab]")].map((b) => b.dataset.tab);
|
|
184
191
|
setCollapsed(state.collapsed);
|
|
185
|
-
|
|
192
|
+
const hidden = embed ? ["deploy"] : [];
|
|
193
|
+
setPanelTab(known.includes(state.tab) && !hidden.includes(state.tab) ? state.tab : known[0], { expand: false });
|
|
186
194
|
window.addEventListener("resize", applyPosition);
|
|
187
195
|
// Tab-content re-renders change the panel's height — a bottom-snapped panel
|
|
188
196
|
// must stay flush through them.
|
package/web/run-playhead.js
CHANGED
|
@@ -186,9 +186,21 @@ export function renderGateCard(gate, actions = {}) {
|
|
|
186
186
|
);
|
|
187
187
|
if (gate.guards) box.appendChild(el("div", "run-gate-meta", `holds back: ${gate.guards}`));
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
// #477: framed in a host, the host approves; the gate is drawn, not offered.
|
|
190
|
+
if (!actions.approve) {
|
|
191
|
+
box.appendChild(el("div", "run-gate-meta", NO_APPROVE[actions.why] || NO_APPROVE.host));
|
|
192
|
+
return box;
|
|
193
|
+
}
|
|
194
|
+
const approve = el("button", "approve", actions.approver ? `Approve ${gate.signalName} as ${actions.approver}` : `Approve ${gate.signalName}`);
|
|
190
195
|
approve.title = `${gate.approve.method} ${gate.approve.path} — the same delegated signal as chant run signal ${gate.op} ${gate.signalName}. behold never approves on its own.`;
|
|
191
196
|
approve.addEventListener("click", () => actions.approve?.(gate.op, gate.signalName));
|
|
192
197
|
box.appendChild(approve);
|
|
193
198
|
return box;
|
|
194
199
|
}
|
|
200
|
+
|
|
201
|
+
// #477: why a gate card has no approve button.
|
|
202
|
+
const NO_APPROVE = {
|
|
203
|
+
host: "approve in the host that framed this graph",
|
|
204
|
+
preview: "approving is off in preview",
|
|
205
|
+
static: "a snapshot: approve where behold runs",
|
|
206
|
+
};
|
package/web/view-url.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// #475: the view in the URL. A host (arugula's workspace block) opens behold
|
|
2
|
+
// at `?member=delivery&zoom=components&env=prod`, a reload keeps the view, and a
|
|
3
|
+
// person can send the link to what they are looking at.
|
|
4
|
+
//
|
|
5
|
+
// Pure logic, no DOM: app.js reads the query once at boot, settles it against
|
|
6
|
+
// what the served estate offers, and writes the view back with
|
|
7
|
+
// history.replaceState as it changes. The static export runs the same SPA, so
|
|
8
|
+
// it takes the same parameters against its captured lens matrix.
|
|
9
|
+
|
|
10
|
+
/** The view's keys, in the order they are written back. Anything else in the
|
|
11
|
+
* query (embed, theme, host, #476) is left where it is. `gates` is the env the
|
|
12
|
+
* gate strip reads when it isn't the graph's (#477); it is the view's because
|
|
13
|
+
* a host can move it with `behold:view`, and a reload has to keep that. */
|
|
14
|
+
export const VIEW_KEYS = ["member", "zoom", "env", "tier", "lens", "node", "radial", "gates"];
|
|
15
|
+
|
|
16
|
+
/** An env name as the server takes one (src/workspace-gates.ts isEnvName). */
|
|
17
|
+
export function isEnvName(env) {
|
|
18
|
+
return typeof env === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(env);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The colour lenses (web/behaviour-scale.js COLOUR_MODES). `lens` in the URL
|
|
22
|
+
* is the colour the graph is painted in. */
|
|
23
|
+
export const LENSES = ["drift", "cost", "headroom"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The view a query asks for. Only the keys present come back; `radial` is a
|
|
27
|
+
* boolean (`1`/`true` on, `0`/`false` off). An empty value is the same as an
|
|
28
|
+
* absent one, except `env=` which asks for the source graph (null).
|
|
29
|
+
*/
|
|
30
|
+
export function readViewQuery(search) {
|
|
31
|
+
const q = new URLSearchParams(search || "");
|
|
32
|
+
const want = {};
|
|
33
|
+
for (const k of VIEW_KEYS) {
|
|
34
|
+
if (!q.has(k)) continue;
|
|
35
|
+
const v = (q.get(k) || "").trim();
|
|
36
|
+
if (k === "radial") {
|
|
37
|
+
if (/^(1|true|on|yes)$/i.test(v)) want.radial = true;
|
|
38
|
+
else if (/^(0|false|off|no)$/i.test(v)) want.radial = false;
|
|
39
|
+
else want.radial = v; // left for settleView to name
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (k === "env") {
|
|
43
|
+
want.env = v || null;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (v) want[k] = v;
|
|
47
|
+
}
|
|
48
|
+
return want;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The query string for a view, keeping every parameter that isn't a view key.
|
|
53
|
+
* Defaults are left out so a plain view keeps a plain URL. `defaults` says
|
|
54
|
+
* what a load with no parameter would give: the env behold was started with
|
|
55
|
+
* (so the source graph under `--env` is written `env=`, or a reload would
|
|
56
|
+
* open live), and the lens saved in this browser (so a link carries a lens
|
|
57
|
+
* that differs from it, drift included).
|
|
58
|
+
*/
|
|
59
|
+
export function writeViewQuery(search, state, defaults = {}) {
|
|
60
|
+
const q = new URLSearchParams(search || "");
|
|
61
|
+
for (const k of VIEW_KEYS) q.delete(k);
|
|
62
|
+
const s = state || {};
|
|
63
|
+
const envDefault = defaults.env || null;
|
|
64
|
+
const lensDefault = defaults.lens || "drift";
|
|
65
|
+
if (s.member) q.set("member", s.member);
|
|
66
|
+
if (s.zoom) q.set("zoom", s.zoom);
|
|
67
|
+
if (s.env) q.set("env", s.env);
|
|
68
|
+
else if (envDefault) q.set("env", "");
|
|
69
|
+
if (s.tier) q.set("tier", s.tier);
|
|
70
|
+
if (s.lens && s.lens !== lensDefault) q.set("lens", s.lens);
|
|
71
|
+
if (s.node) q.set("node", s.node);
|
|
72
|
+
if (s.radial) q.set("radial", "1");
|
|
73
|
+
if (s.gates) q.set("gates", s.gates);
|
|
74
|
+
const out = q.toString();
|
|
75
|
+
return out ? `?${out}` : "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Split what a query asks for into what this estate can honour and what it
|
|
80
|
+
* can't, with the reason. `offer` lists what the served estate has:
|
|
81
|
+
* `{ zooms, envs, tiers, lenses }`. member and node are checked later, against
|
|
82
|
+
* the first graph (they are only known once it is read), so they pass through.
|
|
83
|
+
* A value behold can't honour is named, never dropped without a word.
|
|
84
|
+
*/
|
|
85
|
+
export function settleView(want, offer) {
|
|
86
|
+
const apply = {};
|
|
87
|
+
const refused = [];
|
|
88
|
+
const o = offer || {};
|
|
89
|
+
if ("zoom" in want) {
|
|
90
|
+
if ((o.zooms || []).includes(want.zoom)) apply.zoom = want.zoom;
|
|
91
|
+
else refused.push({ key: "zoom", value: want.zoom, reason: `this estate offers ${listOr(o.zooms)}` });
|
|
92
|
+
}
|
|
93
|
+
if ("env" in want) {
|
|
94
|
+
if (want.env === null || (o.envs || []).includes(want.env)) apply.env = want.env;
|
|
95
|
+
else refused.push({ key: "env", value: want.env, reason: (o.envs || []).length ? `this estate declares ${listOr(o.envs)}` : "this estate declares no environments" });
|
|
96
|
+
}
|
|
97
|
+
if ("tier" in want) {
|
|
98
|
+
if ((o.tiers || []).includes(want.tier)) apply.tier = want.tier;
|
|
99
|
+
else refused.push({ key: "tier", value: want.tier, reason: (o.tiers || []).length ? `this project offers ${listOr(o.tiers)}` : "this project offers no tiers" });
|
|
100
|
+
}
|
|
101
|
+
if ("lens" in want) {
|
|
102
|
+
const lenses = o.lenses || LENSES;
|
|
103
|
+
if (lenses.includes(want.lens)) apply.lens = want.lens;
|
|
104
|
+
else refused.push({ key: "lens", value: want.lens, reason: `the lenses are ${listOr(lenses)}` });
|
|
105
|
+
}
|
|
106
|
+
if ("radial" in want) {
|
|
107
|
+
if (typeof want.radial === "boolean") apply.radial = want.radial;
|
|
108
|
+
else refused.push({ key: "radial", value: want.radial, reason: "radial is 1 or 0" });
|
|
109
|
+
}
|
|
110
|
+
if ("gates" in want) {
|
|
111
|
+
// null: the gate strip follows the graph's env again.
|
|
112
|
+
if (want.gates === null || isEnvName(want.gates)) apply.gates = want.gates;
|
|
113
|
+
else refused.push({ key: "gates", value: want.gates, reason: "an env name is letters, digits, '.', '_' and '-'" });
|
|
114
|
+
}
|
|
115
|
+
if (want.member) apply.member = want.member;
|
|
116
|
+
if (want.node) apply.node = want.node;
|
|
117
|
+
return { apply, refused };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* member and node, checked against a graph that came back. `members` is the
|
|
122
|
+
* set of member names in it, `nodes` the set of node ids. A node in another
|
|
123
|
+
* member than the one asked for is refused; so is one the zoom elided.
|
|
124
|
+
*/
|
|
125
|
+
export function settlePlace(want, members, nodes) {
|
|
126
|
+
const apply = {};
|
|
127
|
+
const refused = [];
|
|
128
|
+
if (want.member) {
|
|
129
|
+
if (members.has(want.member)) apply.member = want.member;
|
|
130
|
+
else refused.push({ key: "member", value: want.member, reason: members.size ? `the members here are ${listOr([...members])}` : "this estate has no members" });
|
|
131
|
+
}
|
|
132
|
+
if (want.node) {
|
|
133
|
+
if (!nodes.has(want.node)) refused.push({ key: "node", value: want.node, reason: "it isn't on this view; the zoom may elide it" });
|
|
134
|
+
else if (apply.member && !want.node.startsWith(`${apply.member}/`)) refused.push({ key: "node", value: want.node, reason: `it isn't in member ${apply.member}` });
|
|
135
|
+
else apply.node = want.node;
|
|
136
|
+
}
|
|
137
|
+
return { apply, refused };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One line for the now line: what the URL asked for that behold didn't do. */
|
|
141
|
+
export function refusalLine(refused) {
|
|
142
|
+
if (!refused.length) return "";
|
|
143
|
+
return "from the link: " + refused.map((r) => `${r.key}=${r.value} not opened, ${r.reason}`).join("; ");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function listOr(xs) {
|
|
147
|
+
const a = (xs || []).filter(Boolean);
|
|
148
|
+
if (!a.length) return "nothing";
|
|
149
|
+
if (a.length > 8) return a.slice(0, 8).join(", ") + ", …";
|
|
150
|
+
return a.join(", ");
|
|
151
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine, isEnvName } from "./view-url.js";
|
|
3
|
+
|
|
4
|
+
describe("readViewQuery (#475)", () => {
|
|
5
|
+
it("reads the view keys and nothing else", () => {
|
|
6
|
+
expect(readViewQuery("?member=delivery&zoom=components&env=prod&node=delivery/appService&embed=1")).toEqual({
|
|
7
|
+
member: "delivery",
|
|
8
|
+
zoom: "components",
|
|
9
|
+
env: "prod",
|
|
10
|
+
node: "delivery/appService",
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
it("reads radial as a boolean, and an empty env as the source graph", () => {
|
|
14
|
+
expect(readViewQuery("?radial=1&env=")).toEqual({ radial: true, env: null });
|
|
15
|
+
expect(readViewQuery("?radial=0")).toEqual({ radial: false });
|
|
16
|
+
});
|
|
17
|
+
it("drops empty values", () => {
|
|
18
|
+
expect(readViewQuery("?member=&zoom=")).toEqual({});
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("writeViewQuery (#475)", () => {
|
|
23
|
+
it("keeps parameters that are not the view's, and leaves defaults out", () => {
|
|
24
|
+
const q = writeViewQuery("?embed=1&host=http%3A%2F%2Fa&member=old", { member: "delivery", zoom: "resources", env: null, lens: "drift", radial: false, node: null });
|
|
25
|
+
expect(q).toBe("?embed=1&host=http%3A%2F%2Fa&member=delivery&zoom=resources");
|
|
26
|
+
});
|
|
27
|
+
it("round-trips through readViewQuery", () => {
|
|
28
|
+
const state = { member: "delivery", zoom: "components", env: "prod", tier: "local", lens: "cost", node: "delivery/appService", radial: true };
|
|
29
|
+
expect(readViewQuery(writeViewQuery("", state))).toEqual(state);
|
|
30
|
+
});
|
|
31
|
+
it("writes env= for the source graph when behold was started on an env, so a reload doesn't open live", () => {
|
|
32
|
+
expect(writeViewQuery("", { zoom: "resources", env: null }, { env: "prod" })).toBe("?zoom=resources&env=");
|
|
33
|
+
expect(readViewQuery("?zoom=resources&env=")).toEqual({ zoom: "resources", env: null });
|
|
34
|
+
});
|
|
35
|
+
it("writes a lens that differs from the saved one, drift included", () => {
|
|
36
|
+
expect(writeViewQuery("", { lens: "drift" }, { lens: "cost" })).toBe("?lens=drift");
|
|
37
|
+
expect(writeViewQuery("", { lens: "cost" }, { lens: "cost" })).toBe("");
|
|
38
|
+
});
|
|
39
|
+
it("writes nothing for an empty view", () => {
|
|
40
|
+
expect(writeViewQuery("", {})).toBe("");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("settleView (#475)", () => {
|
|
45
|
+
const offer = { zooms: ["components", "resources"], envs: ["prod"], tiers: [], lenses: ["drift", "cost", "headroom"] };
|
|
46
|
+
it("applies what the estate offers", () => {
|
|
47
|
+
expect(settleView({ zoom: "resources", env: "prod", lens: "cost", radial: true, member: "m" }, offer)).toEqual({
|
|
48
|
+
apply: { zoom: "resources", env: "prod", lens: "cost", radial: true, member: "m" },
|
|
49
|
+
refused: [],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it("names what it can't honour, with the reason", () => {
|
|
53
|
+
const { apply, refused } = settleView({ zoom: "ops", env: "staging", tier: "aws", radial: "maybe" }, offer);
|
|
54
|
+
expect(apply).toEqual({});
|
|
55
|
+
expect(refused.map((r) => r.key)).toEqual(["zoom", "env", "tier", "radial"]);
|
|
56
|
+
expect(refused[1].reason).toBe("this estate declares prod");
|
|
57
|
+
expect(refused[2].reason).toBe("this project offers no tiers");
|
|
58
|
+
});
|
|
59
|
+
it("takes env= as the source graph", () => {
|
|
60
|
+
expect(settleView({ env: null }, offer).apply).toEqual({ env: null });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("gates, the gate strip's env (#477)", () => {
|
|
65
|
+
it("reads gates from the query, and an empty one as no override", () => {
|
|
66
|
+
expect(readViewQuery("?gates=prod&env=")).toEqual({ env: null, gates: "prod" });
|
|
67
|
+
expect(readViewQuery("?gates=")).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
it("owns gates on write-back: written when set, dropped when cleared", () => {
|
|
70
|
+
expect(writeViewQuery("?embed=1&gates=old", { zoom: "resources", gates: "prod" })).toBe("?embed=1&zoom=resources&gates=prod");
|
|
71
|
+
expect(writeViewQuery("?embed=1&gates=old", { zoom: "resources", gates: null })).toBe("?embed=1&zoom=resources");
|
|
72
|
+
});
|
|
73
|
+
it("round-trips through readViewQuery", () => {
|
|
74
|
+
expect(readViewQuery(writeViewQuery("", { gates: "staging" }))).toEqual({ gates: "staging" });
|
|
75
|
+
});
|
|
76
|
+
it("takes an env name or null, and refuses anything chant would read as a flag", () => {
|
|
77
|
+
expect(settleView({ gates: "prod" }, {}).apply).toEqual({ gates: "prod" });
|
|
78
|
+
expect(settleView({ gates: null }, {}).apply).toEqual({ gates: null });
|
|
79
|
+
const { apply, refused } = settleView({ gates: "--sign" }, {});
|
|
80
|
+
expect(apply).toEqual({});
|
|
81
|
+
expect(refused).toEqual([{ key: "gates", value: "--sign", reason: "an env name is letters, digits, '.', '_' and '-'" }]);
|
|
82
|
+
});
|
|
83
|
+
it("matches the server's env-name check", () => {
|
|
84
|
+
for (const ok of ["local", "prod-eu", "a.b_c", "0"]) expect(isEnvName(ok)).toBe(true);
|
|
85
|
+
for (const bad of ["", "-x", ".x", "a b", "a/b", null]) expect(isEnvName(bad)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("settlePlace (#475)", () => {
|
|
90
|
+
const members = new Set(["delivery", "app"]);
|
|
91
|
+
const nodes = new Set(["delivery/appService", "app/x"]);
|
|
92
|
+
it("opens a member and a node in it", () => {
|
|
93
|
+
expect(settlePlace({ member: "delivery", node: "delivery/appService" }, members, nodes)).toEqual({
|
|
94
|
+
apply: { member: "delivery", node: "delivery/appService" },
|
|
95
|
+
refused: [],
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
it("refuses an unknown member, an absent node, and a node in another member", () => {
|
|
99
|
+
expect(settlePlace({ member: "nope" }, members, nodes).refused[0].reason).toBe("the members here are delivery, app");
|
|
100
|
+
expect(settlePlace({ node: "delivery/gone" }, members, nodes).refused[0].key).toBe("node");
|
|
101
|
+
expect(settlePlace({ member: "delivery", node: "app/x" }, members, nodes).refused[0].reason).toBe("it isn't in member delivery");
|
|
102
|
+
});
|
|
103
|
+
it("says it in one line", () => {
|
|
104
|
+
expect(refusalLine([{ key: "zoom", value: "ops", reason: "this estate offers components" }])).toBe(
|
|
105
|
+
"from the link: zoom=ops not opened, this estate offers components",
|
|
106
|
+
);
|
|
107
|
+
expect(refusalLine([])).toBe("");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// #477: a declared workspace's waiting gates, as cards. Pure: app.js fetches
|
|
2
|
+
// /api/workspace/gates and paints what gateCards returns.
|
|
3
|
+
//
|
|
4
|
+
// One approve path per gate. Framed in a host (#476), behold draws the gate
|
|
5
|
+
// and no button: the host knows who is looking and records it. Standalone, the
|
|
6
|
+
// button stays, and the card says the approval is recorded as the user running
|
|
7
|
+
// behold, since behold has no other identity to give chant.
|
|
8
|
+
|
|
9
|
+
/** The words a framed card uses instead of a button. */
|
|
10
|
+
export const HOST_APPROVES = "approve in the host that framed this graph";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cards for a gates answer. `embed` is whether behold is framed; `member`
|
|
14
|
+
* narrows to one member's gates when a host opened behold on it (the rest are
|
|
15
|
+
* counted, not hidden without a word).
|
|
16
|
+
*/
|
|
17
|
+
export function gateCards(answer, { embed = false, member = null } = {}) {
|
|
18
|
+
const all = (answer && answer.gates) || [];
|
|
19
|
+
const shown = member ? all.filter((g) => g.member === member) : all;
|
|
20
|
+
const cards = shown.map((g) => {
|
|
21
|
+
const facts = [`${g.approvals.length} of ${g.needed} approval${g.needed === 1 ? "" : "s"}`];
|
|
22
|
+
if (g.env) facts.push(`env ${g.env}`);
|
|
23
|
+
if (g.planDigest) facts.push(`plan ${shortDigest(g.planDigest)}`);
|
|
24
|
+
if (g.approvals.length) facts.push(`approved by ${g.approvals.map((a) => a.principal).join(", ")}`);
|
|
25
|
+
const card = {
|
|
26
|
+
key: g.key,
|
|
27
|
+
member: g.member,
|
|
28
|
+
title: `${g.member}: ${g.op} waits at gate ${g.gate}`,
|
|
29
|
+
facts: facts.join(" · "),
|
|
30
|
+
command: g.approve,
|
|
31
|
+
};
|
|
32
|
+
if (embed) card.note = HOST_APPROVES;
|
|
33
|
+
else if (g.signed) card.note = `wants a signed approval; run ${g.approve}`;
|
|
34
|
+
else card.button = { label: `Approve as ${answer.approver || "you"}`, title: `${g.approve}, run by behold. chant records the approval as ${answer.approver || "the user running behold"}.` };
|
|
35
|
+
return card;
|
|
36
|
+
});
|
|
37
|
+
const elsewhere = all.length - shown.length;
|
|
38
|
+
return { cards, elsewhere, refusal: (answer && answer.refusal) || null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shortDigest(d) {
|
|
42
|
+
const s = String(d).replace(/^sha256:/, "");
|
|
43
|
+
return s.length > 12 ? s.slice(0, 12) : s;
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { gateCards, HOST_APPROVES } from "./workspace-gates.js";
|
|
3
|
+
|
|
4
|
+
const answer = {
|
|
5
|
+
workspace: true,
|
|
6
|
+
env: "local",
|
|
7
|
+
approver: "alex",
|
|
8
|
+
gates: [
|
|
9
|
+
{ key: "delivery/ship/approve-ship", member: "delivery", op: "ship", gate: "approve-ship", env: null, planDigest: null, approvals: [], needed: 1, signed: false, approve: "chant approve ship approve-ship" },
|
|
10
|
+
{ key: "infra/deploy/go", member: "infra", op: "deploy", gate: "go", env: "prod", planDigest: "sha256:0123456789abcdef", approvals: [{ principal: "jake" }], needed: 2, signed: false, approve: "chant approve deploy go --env prod" },
|
|
11
|
+
],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("gateCards (#477)", () => {
|
|
15
|
+
it("offers approve standalone, and says who chant records", () => {
|
|
16
|
+
const { cards } = gateCards(answer);
|
|
17
|
+
expect(cards[0]).toMatchObject({ title: "delivery: ship waits at gate approve-ship", facts: "0 of 1 approval", button: { label: "Approve as alex" } });
|
|
18
|
+
expect(cards[1].facts).toBe("1 of 2 approvals · env prod · plan 0123456789ab · approved by jake");
|
|
19
|
+
});
|
|
20
|
+
it("draws the gate and no button when framed", () => {
|
|
21
|
+
const { cards } = gateCards(answer, { embed: true });
|
|
22
|
+
expect(cards.every((c) => !c.button && c.note === HOST_APPROVES)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it("narrows to one member and counts the rest", () => {
|
|
25
|
+
const { cards, elsewhere } = gateCards(answer, { embed: true, member: "delivery" });
|
|
26
|
+
expect(cards.map((c) => c.key)).toEqual(["delivery/ship/approve-ship"]);
|
|
27
|
+
expect(elsewhere).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
it("sends a signed gate to the command line", () => {
|
|
30
|
+
const { cards } = gateCards({ ...answer, gates: [{ ...answer.gates[0], signed: true, approve: "chant approve ship approve-ship --sign" }] });
|
|
31
|
+
expect(cards[0].button).toBeUndefined();
|
|
32
|
+
expect(cards[0].note).toBe("wants a signed approval; run chant approve ship approve-ship --sign");
|
|
33
|
+
});
|
|
34
|
+
});
|