@intentius/behold 0.14.0 → 0.15.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/AGENTS.md +43 -0
- package/dist/cli.js +75 -0
- package/package.json +1 -1
- package/web/app.js +32 -1
- package/web/release.js +66 -0
- package/web/release.test.js +52 -0
package/AGENTS.md
CHANGED
|
@@ -54,6 +54,13 @@ preview-locked, and one load runs at a time (409 otherwise).
|
|
|
54
54
|
per-node observed state, drift and field ownership; `/api/reconcile?env=`
|
|
55
55
|
summarizes the pending change set; `/api/substrates` reports substrate
|
|
56
56
|
readiness; `/api/events` (SSE) pushes `changed`/`op`/`apply`/`run`/`pr`.
|
|
57
|
+
With `?components=1&env=`, each component node carries `_liveStatus` (the
|
|
58
|
+
`chant components status` verdict) and, when the release ledger recorded
|
|
59
|
+
one, `_release` — `{runId, forge, originSource, url?, gitSha, digest,
|
|
60
|
+
timestamp, actor, approver?}` (#165). `url` is present only when the record
|
|
61
|
+
itself carries an address (chant#2045); `originSource: "inferred"` means
|
|
62
|
+
behold read the id's spelling and nothing more, and a `forge: "unknown"` id
|
|
63
|
+
is never resolved to a link on behold's guess.
|
|
57
64
|
`?ops=1` is the ops lens — the project's declared Ops as a phase track, read
|
|
58
65
|
from each emitted `dist/ops/<name>/op.json`, with the current run painted
|
|
59
66
|
over it (`meta.run`, `meta.gate`); `/api/ops/<name>/status` reads that Op's
|
|
@@ -257,3 +264,39 @@ own Terraform is not edited either. The only request-derived value is `select`,
|
|
|
257
264
|
and it must be an address the served report already ranks — the value that
|
|
258
265
|
reaches the spawn's argv comes from a closed set read off disk. No cloud write,
|
|
259
266
|
no Terraform mutation, no edit to anyone's chant source.
|
|
267
|
+
|
|
268
|
+
## Changing behold (for agents working on this repo)
|
|
269
|
+
|
|
270
|
+
The sections above are about driving a running behold. This one is about
|
|
271
|
+
changing it — the working rules that used to live in a handoff note and now
|
|
272
|
+
live here, with the enforceable ones enforced.
|
|
273
|
+
|
|
274
|
+
- **Branches and merges.** Work on a branch off `main` (an isolated worktree
|
|
275
|
+
when more than one agent is active). `main` is protected by a ruleset: every
|
|
276
|
+
change arrives as a PR, and the `check` job in `ci.yml` must be green before
|
|
277
|
+
it merges. A check that has merely settled is not a pass — read the lines.
|
|
278
|
+
No `gh pr merge --auto`; merge one PR at a time, in order, and rebase the
|
|
279
|
+
next onto what landed.
|
|
280
|
+
- **The local gate** is `just check` (tsc, tests, build). `just test` keeps
|
|
281
|
+
the reporter's full output in `vitest.log`, because #334's one-shot
|
|
282
|
+
first-run failures kept losing the failing file's name — if the suite fails
|
|
283
|
+
once and passes on rerun, the log from the first run is the evidence; attach
|
|
284
|
+
it to #334.
|
|
285
|
+
- **Releasing** is `just release`, after the version-bump PR merged: it tags
|
|
286
|
+
`behold-v<version>`, pushes the tag (which is what runs `release.yml`), and
|
|
287
|
+
waits for npm to show the version. It refuses in every state where a tag
|
|
288
|
+
push would be wrong. Never re-push a `behold-v*` tag by hand — a tag push
|
|
289
|
+
re-fires publish. Tags absent from origin (behold-v0.10.0, 0.10.1) stay
|
|
290
|
+
absent for that reason.
|
|
291
|
+
- **Scratch infrastructure** is governed by `src/scratch.ts`, asserted by
|
|
292
|
+
`scratch.test.ts` across every boot site: anything behold boots is named
|
|
293
|
+
`behold-*`, refuses if the name is taken, tears down only what it created,
|
|
294
|
+
and never binds the shared emulator ports. The protected names — `floci*`,
|
|
295
|
+
`chant-floci*`, the kubemicrovm and fountain k3d clusters, :4566 — are
|
|
296
|
+
listed there, not in prose. A new boot site calls `assertScratch` before
|
|
297
|
+
its spawn and gets a row in the test.
|
|
298
|
+
- **The write boundary** is the Invariant section above. Apply is never a
|
|
299
|
+
button; `/api/carve/apply` does not exist and the route test asserts it
|
|
300
|
+
stays that way.
|
|
301
|
+
- **chant's working rules** (its single-lane checkout, its release path) are
|
|
302
|
+
chant's and live in chant's repo, not here.
|
package/dist/cli.js
CHANGED
|
@@ -2871,6 +2871,50 @@ function componentStatusColor(row) {
|
|
|
2871
2871
|
return "neutral";
|
|
2872
2872
|
}
|
|
2873
2873
|
}
|
|
2874
|
+
function httpUrl(url) {
|
|
2875
|
+
if (typeof url !== "string" || !url) return void 0;
|
|
2876
|
+
try {
|
|
2877
|
+
const p = new URL(url);
|
|
2878
|
+
return p.protocol === "http:" || p.protocol === "https:" ? url : void 0;
|
|
2879
|
+
} catch {
|
|
2880
|
+
return void 0;
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
function releaseOrigin(rec6) {
|
|
2884
|
+
const origin = rec6.runOrigin;
|
|
2885
|
+
if (origin && typeof origin.forge === "string") {
|
|
2886
|
+
const url = httpUrl(origin.url);
|
|
2887
|
+
return {
|
|
2888
|
+
forge: origin.forge,
|
|
2889
|
+
originSource: "record",
|
|
2890
|
+
...origin.repo ? { repo: origin.repo } : {},
|
|
2891
|
+
...url ? { url } : {}
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
return { forge: rec6.runId.startsWith("local-") ? "local" : "unknown", originSource: "inferred" };
|
|
2895
|
+
}
|
|
2896
|
+
var FORGE_WORD = {
|
|
2897
|
+
github: "github",
|
|
2898
|
+
gitlab: "gitlab",
|
|
2899
|
+
op: "op",
|
|
2900
|
+
local: "laptop",
|
|
2901
|
+
unknown: "?"
|
|
2902
|
+
};
|
|
2903
|
+
function releaseChip(status) {
|
|
2904
|
+
return `${status.gitSha.slice(0, 7)} via ${FORGE_WORD[status.forge]}`;
|
|
2905
|
+
}
|
|
2906
|
+
function releaseStatus(rec6) {
|
|
2907
|
+
if (!rec6) return void 0;
|
|
2908
|
+
return {
|
|
2909
|
+
runId: rec6.runId,
|
|
2910
|
+
...releaseOrigin(rec6),
|
|
2911
|
+
gitSha: rec6.gitSha,
|
|
2912
|
+
digest: rec6.digest,
|
|
2913
|
+
timestamp: rec6.timestamp,
|
|
2914
|
+
actor: rec6.actor,
|
|
2915
|
+
...rec6.approver ? { approver: rec6.approver } : {}
|
|
2916
|
+
};
|
|
2917
|
+
}
|
|
2874
2918
|
function joinComponentStatus(ir, rows) {
|
|
2875
2919
|
const byComponent = new Map(rows.map((r) => [r.component, r]));
|
|
2876
2920
|
return {
|
|
@@ -2878,10 +2922,12 @@ function joinComponentStatus(ir, rows) {
|
|
|
2878
2922
|
nodes: ir.nodes.map((n) => {
|
|
2879
2923
|
const row = byComponent.get(n.id);
|
|
2880
2924
|
if (!row) return n;
|
|
2925
|
+
const release = releaseStatus(row.recorded);
|
|
2881
2926
|
return {
|
|
2882
2927
|
...n,
|
|
2883
2928
|
attrs: {
|
|
2884
2929
|
...n.attrs,
|
|
2930
|
+
...release ? { release: releaseChip(release), _release: release } : {},
|
|
2885
2931
|
_status: componentStatusColor(row),
|
|
2886
2932
|
_liveStatus: {
|
|
2887
2933
|
reconciliation: row.reconciliation,
|
|
@@ -6595,6 +6641,34 @@ function reanchorEdges(svg, moved) {
|
|
|
6595
6641
|
import { spawn as spawn3 } from "node:child_process";
|
|
6596
6642
|
import { readFileSync as readFileSync9, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "node:fs";
|
|
6597
6643
|
import { join as join12 } from "node:path";
|
|
6644
|
+
|
|
6645
|
+
// src/scratch.ts
|
|
6646
|
+
var PROTECTED_INFRA = [
|
|
6647
|
+
/^floci(-|$)/,
|
|
6648
|
+
/^chant-floci(-|$)/,
|
|
6649
|
+
/^k3d-kubemicrovm-local$/,
|
|
6650
|
+
/^k3d-fountain-local$/,
|
|
6651
|
+
/^kubemicrovm-local$/,
|
|
6652
|
+
/^fountain-local$/
|
|
6653
|
+
];
|
|
6654
|
+
var PROTECTED_PORTS = [4566, 4577, 4588];
|
|
6655
|
+
var SCRATCH_PREFIX = "behold-";
|
|
6656
|
+
function scratchNameProblem(name) {
|
|
6657
|
+
if (!name.startsWith(SCRATCH_PREFIX)) return `scratch infra must be named ${SCRATCH_PREFIX}*, got ${JSON.stringify(name)}`;
|
|
6658
|
+
const hit = PROTECTED_INFRA.find((re) => re.test(name));
|
|
6659
|
+
if (hit) return `${JSON.stringify(name)} is protected infrastructure (${hit}) \u2014 behold never touches it`;
|
|
6660
|
+
return void 0;
|
|
6661
|
+
}
|
|
6662
|
+
function scratchPortProblem(port) {
|
|
6663
|
+
if (PROTECTED_PORTS.includes(port)) return `port ${port} belongs to a shared emulator \u2014 a scratch substrate gets its own`;
|
|
6664
|
+
return void 0;
|
|
6665
|
+
}
|
|
6666
|
+
function assertScratch(name, port) {
|
|
6667
|
+
const p = scratchNameProblem(name) ?? (port === void 0 ? void 0 : scratchPortProblem(port));
|
|
6668
|
+
if (p) throw new Error(`scratch discipline: ${p}`);
|
|
6669
|
+
}
|
|
6670
|
+
|
|
6671
|
+
// src/carve-live.ts
|
|
6598
6672
|
var LIVE_CONTAINER = "behold-carve-floci";
|
|
6599
6673
|
var LIVE_PORT = 4602;
|
|
6600
6674
|
var LIVE_IMAGE = "floci/floci:1.5.34";
|
|
@@ -6627,6 +6701,7 @@ function capture(cmd, args, cwd) {
|
|
|
6627
6701
|
});
|
|
6628
6702
|
}
|
|
6629
6703
|
async function bootScratchFloci() {
|
|
6704
|
+
assertScratch(LIVE_CONTAINER, LIVE_PORT);
|
|
6630
6705
|
const ps = await capture("docker", ["ps", "-a", "--format", "{{.Names}}"], process.cwd());
|
|
6631
6706
|
if (ps.code !== 0) return "docker isn't answering \u2014 is the daemon up?";
|
|
6632
6707
|
if (ps.stdout.split("\n").includes(LIVE_CONTAINER)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/behold",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
|
|
6
6
|
"bin": {
|
package/web/app.js
CHANGED
|
@@ -52,6 +52,10 @@ import { hasPlayhead, renderPlayhead, waitingFor } from "./run-playhead.js";
|
|
|
52
52
|
// records a fact for the next tick rather than releasing anything. Same split as
|
|
53
53
|
// the playhead — the decisions are pure in there, the fetches stay here.
|
|
54
54
|
import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
|
|
55
|
+
// #165: the recorded release on a component — which run put it here, from
|
|
56
|
+
// which commit, approved by whom — and, when the ledger's run id cannot be
|
|
57
|
+
// followed, the reason in words rather than a guessed link.
|
|
58
|
+
import { releaseRows } from "./release.js";
|
|
55
59
|
initTheme();
|
|
56
60
|
initPanel();
|
|
57
61
|
mountThemePicker(document.getElementById("panel-theme"));
|
|
@@ -542,8 +546,35 @@ function inspect(node) {
|
|
|
542
546
|
}
|
|
543
547
|
}
|
|
544
548
|
|
|
549
|
+
// The recorded release (#165, #61's second clause): the ledger row behold
|
|
550
|
+
// reads on `chant components status` and, until now, read past. Its own
|
|
551
|
+
// section beside "live status", because the two answer different questions
|
|
552
|
+
// — what is running versus what was recorded putting it there — and the
|
|
553
|
+
// reconciliation verdict above is exactly their comparison. The run is a link
|
|
554
|
+
// only when the record itself carries an address (chant#2045); a bare id
|
|
555
|
+
// gets its reason, never a guessed forge (web/release.js).
|
|
556
|
+
const release = node.attrs && node.attrs._release;
|
|
557
|
+
if (release) {
|
|
558
|
+
const rel = section("release");
|
|
559
|
+
for (const [k, v] of releaseRows(release)) {
|
|
560
|
+
if (v && typeof v === "object" && v.href) {
|
|
561
|
+
const a = document.createElement("a");
|
|
562
|
+
a.href = v.href;
|
|
563
|
+
a.textContent = v.text;
|
|
564
|
+
a.target = "_blank";
|
|
565
|
+
a.rel = "noreferrer noopener";
|
|
566
|
+
a.title = `${v.href} — the address the release record itself carries (chant#2045). behold never invents this link.`;
|
|
567
|
+
rel(k, a);
|
|
568
|
+
} else {
|
|
569
|
+
rel(k, v);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
545
574
|
// Declared attributes — the source-of-truth values / cross-resource refs.
|
|
546
|
-
|
|
575
|
+
// The `release` chip is the card's abbreviation of the section above, not a
|
|
576
|
+
// declared value, so it stays out of this list when the section rendered.
|
|
577
|
+
const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
|
|
547
578
|
if (attrKeys.length) {
|
|
548
579
|
const decl = section("declared");
|
|
549
580
|
for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
|
package/web/release.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// #165 (#61's second clause): the recorded release, read off a component
|
|
2
|
+
// node's `_release` attr (src/component-status.ts's `releaseStatus`). The pure
|
|
3
|
+
// half, tested the way web/operator.js and web/run-playhead.js are — no DOM.
|
|
4
|
+
// The card chip (`release`) already answers "which commit, through what"; this
|
|
5
|
+
// is the pane's fuller answer — which run, from which commit, approved by whom
|
|
6
|
+
// — and, when the record cannot be followed, the reason in words.
|
|
7
|
+
import { approvalLink } from "./operator.js";
|
|
8
|
+
import { waitingFor } from "./run-playhead.js";
|
|
9
|
+
|
|
10
|
+
/** How the pane names each id space. `laptop` for a local mint: "local" would
|
|
11
|
+
* read as a place, and the point is that there is nowhere to go. */
|
|
12
|
+
export const FORGE_LABEL = { github: "GitHub Actions", gitlab: "GitLab CI", op: "an Op run", local: "a laptop run", unknown: "an unlinked run" };
|
|
13
|
+
|
|
14
|
+
/** The one line that says why there is no link, or null when there is one.
|
|
15
|
+
* Never a placeholder link, never a guessed forge: the three id spaces a
|
|
16
|
+
* `runId` can live in are mutually incompatible, so a link behold built from
|
|
17
|
+
* a bare id would as often be wrong as right (chant#2045). */
|
|
18
|
+
export function unlinkedReason(release) {
|
|
19
|
+
if (!release) return null;
|
|
20
|
+
if (runLink(release)) return null;
|
|
21
|
+
switch (release.forge) {
|
|
22
|
+
case "local":
|
|
23
|
+
return release.originSource === "record"
|
|
24
|
+
? "minted on the machine that deployed — the record says so; there is no run to open"
|
|
25
|
+
: "minted on the machine that deployed (a local- id) — there is no run to open";
|
|
26
|
+
case "unknown":
|
|
27
|
+
return "the record names a run id and nothing else — no forge, no repo, no URL. A chant older than chant#2045 wrote it; behold will not guess which forge the id belongs to";
|
|
28
|
+
case "op":
|
|
29
|
+
return "an orchestrator run id — the run lives in Temporal, not on a forge";
|
|
30
|
+
default:
|
|
31
|
+
return `${FORGE_LABEL[release.forge] || release.forge} named no server URL when this was recorded, so the id cannot be resolved to a link`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The run's absolute http(s) URL, or null. Same validator as the gate card's
|
|
36
|
+
* `approve at:` — an address the ledger itself carries, or nothing. */
|
|
37
|
+
export function runLink(release) {
|
|
38
|
+
return release && release.originSource === "record" ? approvalLink(release.url) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `<runId> (<forge>)` — the run row's text, link or no link. */
|
|
42
|
+
export function runText(release) {
|
|
43
|
+
const forge = FORGE_LABEL[release.forge] || release.forge;
|
|
44
|
+
return release.repo ? `${release.runId} · ${forge} · ${release.repo}` : `${release.runId} · ${forge}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pane's rows, in order: `[key, value]` pairs plus an optional caveat
|
|
48
|
+
* row keyed `""`. The link is returned as `{href, text}` so the DOM half makes
|
|
49
|
+
* the anchor and this half stays testable. */
|
|
50
|
+
export function releaseRows(release, now = Date.now()) {
|
|
51
|
+
if (!release) return [];
|
|
52
|
+
const rows = [];
|
|
53
|
+
const href = runLink(release);
|
|
54
|
+
rows.push(["run", href ? { href, text: runText(release) } : runText(release)]);
|
|
55
|
+
const why = unlinkedReason(release);
|
|
56
|
+
if (why) rows.push(["", why]);
|
|
57
|
+
rows.push(["commit", release.gitSha]);
|
|
58
|
+
rows.push(["digest", release.digest]);
|
|
59
|
+
const ago = release.timestamp ? waitingFor(release.timestamp, now) : null;
|
|
60
|
+
rows.push(["recorded", ago ? `${release.timestamp} (${ago} ago)` : release.timestamp]);
|
|
61
|
+
rows.push(["actor", release.actor]);
|
|
62
|
+
// Absent means ungated — chant omits `approver` exactly then — so the row
|
|
63
|
+
// says that rather than leaving a gap the reader could take for a redaction.
|
|
64
|
+
rows.push(["approver", release.approver || "none — an ungated change"]);
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { releaseRows, runLink, unlinkedReason, runText } from "../web/release.js";
|
|
3
|
+
|
|
4
|
+
const NOW = Date.parse("2026-09-01T12:00:00Z");
|
|
5
|
+
const base = { runId: "18234771902", gitSha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", digest: "sha256:86bf5e3c", timestamp: "2026-09-01T11:30:00Z", actor: "ci-bot" };
|
|
6
|
+
|
|
7
|
+
describe("release rows (#165)", () => {
|
|
8
|
+
it("a record that carries its own address links the run, and names forge + repo", () => {
|
|
9
|
+
const r = { ...base, forge: "github", originSource: "record", repo: "INTENTIUS/loomster", url: "https://github.com/INTENTIUS/loomster/actions/runs/18234771902", approver: "alex" };
|
|
10
|
+
const rows = releaseRows(r, NOW);
|
|
11
|
+
expect(rows[0]).toEqual(["run", { href: r.url, text: "18234771902 · GitHub Actions · INTENTIUS/loomster" }]);
|
|
12
|
+
expect(rows.find(([k]) => k === "")).toBeUndefined();
|
|
13
|
+
expect(rows.find(([k]) => k === "approver")[1]).toBe("alex");
|
|
14
|
+
expect(rows.find(([k]) => k === "recorded")[1]).toBe("2026-09-01T11:30:00Z (30m ago)");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("a bare id from a pre-chant#2045 record is never linked, and the caveat says why", () => {
|
|
18
|
+
const r = { ...base, forge: "unknown", originSource: "inferred" };
|
|
19
|
+
expect(runLink(r)).toBeNull();
|
|
20
|
+
expect(unlinkedReason(r)).toMatch(/chant#2045/);
|
|
21
|
+
expect(unlinkedReason(r)).toMatch(/will not guess/);
|
|
22
|
+
expect(releaseRows(r, NOW)[0]).toEqual(["run", "18234771902 · an unlinked run"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("a laptop run says there is nowhere to go — whether the record said local or the id's spelling did", () => {
|
|
26
|
+
const said = { ...base, runId: "local-1756726200000", forge: "local", originSource: "record" };
|
|
27
|
+
const spelled = { ...base, runId: "local-1756726200000", forge: "local", originSource: "inferred" };
|
|
28
|
+
expect(unlinkedReason(said)).toMatch(/the record says so/);
|
|
29
|
+
expect(unlinkedReason(spelled)).toMatch(/a local- id/);
|
|
30
|
+
expect(runText(said)).toBe("local-1756726200000 · a laptop run");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("an inferred origin never yields a link even if a url is somehow present", () => {
|
|
34
|
+
expect(runLink({ ...base, forge: "github", originSource: "inferred", url: "https://example.com/run" })).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("a recorded origin with a non-http url is not a link — the gate card's rule", () => {
|
|
38
|
+
const r = { ...base, forge: "github", originSource: "record", url: "javascript:alert(1)" };
|
|
39
|
+
expect(runLink(r)).toBeNull();
|
|
40
|
+
expect(unlinkedReason(r)).toMatch(/named no server URL/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("an ungated change says so in the approver row rather than leaving a gap", () => {
|
|
44
|
+
const rows = releaseRows({ ...base, forge: "op", originSource: "record" }, NOW);
|
|
45
|
+
expect(rows.find(([k]) => k === "approver")[1]).toBe("none — an ungated change");
|
|
46
|
+
expect(unlinkedReason({ ...base, forge: "op", originSource: "record" })).toMatch(/Temporal/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("nothing recorded → no rows", () => {
|
|
50
|
+
expect(releaseRows(undefined)).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
});
|