@volter/world-console 2.0.5 → 2.0.6

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.
@@ -197,3 +197,27 @@ section.pitch { background: none; border: 0; box-shadow: none; padding: 0; margi
197
197
  /* a phone: the top bar keeps the menu, the mark, where you are, the way back as an arrow and Sign out */
198
198
  @media (max-width: 720px) { main.app > header .mode, main.app > header .back-link .back-text { display: none; } main.app > header .back-link { flex: none; } }
199
199
  @media (max-width: 720px) { .head-actions .clock { flex-wrap: wrap; max-width: 100%; } }
200
+
201
+ /* changesets, reviewed as pull requests are: a status word, the changes by vendor, checks, approvals, receipts */
202
+ .badge.cs-done { background: var(--accent-soft); color: var(--ink); }
203
+ .badge.cs-good { color: var(--ok); border-color: var(--ok); }
204
+ .badge.cs-warn { background: var(--warn-soft); color: var(--warn); }
205
+ .badge.cs-bad { background: var(--danger-soft); color: var(--danger-text); }
206
+ .changeset-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 8px; }
207
+ .changeset-head h3 { margin: 0 0 4px; }
208
+ .changeset-actions { display: flex; gap: 8px; flex-wrap: wrap; }
209
+ .changeset h4 { margin: 22px 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
210
+ .changeset .narration { white-space: pre-wrap; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; font-size: 13px; }
211
+ .changeset .stale { opacity: 0.75; }
212
+ .changeset ul.checks, .changeset ul.approvals, .changeset ul.receipts { list-style: none; padding: 0; margin: 6px 0; display: grid; gap: 6px; }
213
+ .crumbs-line { margin: 0 0 12px; font-size: 13px; }
214
+ /* the branches page: its words, and New branch beside them */
215
+ .branches-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
216
+ .branches-head .lede { margin-top: 0; }
217
+ /* a dialog's text box reads in the page's face, not the browser's monospace default */
218
+ dialog textarea { font: inherit; width: 100%; }
219
+ /* a set of choices (a branch's moment): its legend a label, its options one per line */
220
+ fieldset.choice { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
221
+ fieldset.choice > legend { padding: 0; margin-bottom: 4px; color: var(--muted); font-size: 12.5px; }
222
+ fieldset.choice > label.check { display: flex; align-items: flex-start; gap: 8px; color: var(--ink); font-size: 13px; }
223
+ fieldset.choice > label.check input { margin-top: 3px; }
@@ -4,7 +4,7 @@
4
4
  // answer, and offers nothing `volter world` or `curl` could not do. Locators are roles and text.
5
5
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { Background, BackgroundVariant, Controls, MiniMap, NodeToolbar, Panel, Position, ReactFlow, ReactFlowProvider, useNodesState, useReactFlow, type Node, type NodeProps } from '@xyflow/react';
7
- import { applyTheme, ThemeChoice, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, failure, listed, Mark, Menu, Pending, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
7
+ import { applyTheme, ThemeChoice, At, Brand, ConfirmHost, MenuDismiss, confirmAct, CopyField, counted, Dialog, DrawerButton, Empty, failure, listed, Mark, Menu, Pending, Toaster, toast, useDoor as useKitDoor, vendorName, when, ago, type Load } from './kit/kit.tsx';
8
8
  import { createRoot } from 'react-dom/client';
9
9
  import { agentPrompt } from '../../../packages/cli/src/agent-prompt.ts';
10
10
 
@@ -86,7 +86,7 @@ const actorOf = (e: Entry): string => (e.actor ? `${e.actor.kind}${e.actor.id ?
86
86
  const traceOf = (tp?: string): { trace: string; span: string } | null => { const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(tp ?? ''); return m ? { trace: m[1]!, span: m[2]! } : null; };
87
87
 
88
88
  /** A past moment of a World, opened by this tab: a branch the host made, held with its tokens for the tab's life. */
89
- type Snapshot = { name: string; parent: string; token: string; readToken: string; at: string; expiresAt: string | null; /** where the parent's pages live, the way back */ parentOrigin?: string; /** this tab's own "View as of" made it here: only such a branch is ever removed */ made?: boolean };
89
+ type Snapshot = { name: string; parent: string; token: string; readToken: string; at: string; expiresAt: string | null; /** where the parent's pages live, the way back */ parentOrigin?: string; /** this tab's own "View as of" made it here: only such a branch is ever removed */ made?: boolean; /** the branch's own origin, where its pages live */ origin?: string };
90
90
  const SNAP_KEY = 'volter.console.snapshots';
91
91
  const snapshots = (): Record<string, Snapshot> => { try { return JSON.parse(sessionStorage.getItem(SNAP_KEY) ?? '{}') as Record<string, Snapshot>; } catch { return {}; } };
92
92
  // on a World origin a snapshot's tokens stay in this module (no frame can reach it), never in storage
@@ -168,6 +168,12 @@ const WORLD_PART = /^[a-z0-9](?:[a-z0-9]|-(?!-))*$/i;
168
168
  * encoded `/` or `..`) ever reaches a door's path. */
169
169
  const worldOf = (o?: string, w?: string): string => (o && w && WORLD_PART.test(o) && WORLD_PART.test(w) ? `${o}/${w}` : '');
170
170
  function go(parts: string[]): void { window.history.pushState(null, '', `${BASE}/${parts.map(encodeURIComponent).join('/')}`); window.dispatchEvent(new PopStateEvent('popstate')); }
171
+ /** A page of a branch this tab holds: at the branch's own origin when it has one (this origin serves its World only), carrying its read token. */
172
+ function goBranch(branch: string, parts: string[]): void {
173
+ const there = worldOriginOf(snapshots()[branch]?.origin);
174
+ if (there && there !== ORIGIN) { moveTo(there, snapshotToken(branch), branch, `${BASE}/${parts.map(encodeURIComponent).join('/')}`); return; }
175
+ go(parts);
176
+ }
171
177
 
172
178
  /** A World this machine serves on loopback opens its session for its own page with no token (served-world.ts
173
179
  * localSession): nothing is ever pasted, and a restart or a rotated token is picked up by asking again. Asked of the
@@ -325,9 +331,9 @@ function App(): React.JSX.Element {
325
331
  const back = worldOriginOf(s.parentOrigin);
326
332
  if (back && back !== ORIGIN) { forgetSnapshot(s.name); window.location.assign(`${back}${BASE}/${s.parent}#close=${encodeURIComponent(s.name)}`); return; }
327
333
  // the branch goes with the way back: a snapshot is a throwaway World, not something to leave running (one this
328
- // tab made; a carried one is only left)
329
- if (s.made) await door(worldToken(s.parent), `/-/${s.parent}/branches/${s.name}`, { method: 'DELETE' }).catch(() => null);
330
- forgetSnapshot(s.name); go(s.parent.split('/'));
334
+ // tab made as a snapshot; a carried one, or a branch made on the Branches page, is only left)
335
+ if (s.made) { await door(worldToken(s.parent), `/-/${s.parent}/branches/${s.name}`, { method: 'DELETE' }).catch(() => null); forgetSnapshot(s.name); }
336
+ go(s.parent.split('/'));
331
337
  };
332
338
  return (
333
339
  <main className={name ? 'app' : undefined}>
@@ -427,7 +433,7 @@ type Scope = 'pending' | 'read' | 'write' | 'unknown';
427
433
  // A World's page is laid out as Neon's console is (Volter is Neon for every SaaS, docs/concepts/the-model.md): the
428
434
  // World's own pages, the branch picker, then the branch's pages and its services, which here are its vendors, each a
429
435
  // sibling. All screens is a canvas app's page, the whole window. The older page names still open their page.
430
- const TABS = [['overview', 'Dashboard'], ['branches', 'Branches'], ['settings', 'Settings'], ['branch', 'Overview'], ['changes', 'Changes'], ['activity', 'Activity'], ['vendors', 'Vendors'], ['canvas', 'All screens']] as const;
436
+ const TABS = [['overview', 'Dashboard'], ['branches', 'Branches'], ['settings', 'Settings'], ['branch', 'Overview'], ['changes', 'Changes'], ['changesets', 'Changesets'], ['activity', 'Activity'], ['vendors', 'Vendors'], ['canvas', 'All screens']] as const;
431
437
  type Tab = (typeof TABS)[number][0];
432
438
  const TAB_ALIASES: Record<string, Tab> = { dashboard: 'overview', mirrors: 'vendors', screens: 'canvas', timeline: 'activity', monitoring: 'activity', trace: 'activity', operations: 'settings' };
433
439
  /** A vendor's own screens, at the twin's place in the World: the vendor's site (served-world.ts, the wire's page fallback). */
@@ -462,14 +468,17 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
462
468
  const [scope, setScope] = useState<Scope>('pending');
463
469
  // why the session was not opened (the wall says it, with a way to try again), and that try
464
470
  const [sessionError, setSessionError] = useState<string | null>(null); const [attempt, setAttempt] = useState(0);
471
+ // whom an approval from this session is recorded as; null: the page names them (a session the World's token opened)
472
+ const [signsAs, setSignsAs] = useState<string | null>(null);
465
473
  // biome-ignore lint/correctness/useExhaustiveDependencies: `attempt` is the wall's "Try again"
466
474
  useEffect(() => {
467
475
  let live = true; setSessionError(null);
468
476
  void door(token, `/-/${name}/session`, { method: 'POST' }).then(async (r) => {
469
- const b = (await r.json().catch(() => ({}))) as { scope?: string; origin?: string; error?: string; who?: string; issuer?: string };
477
+ const b = (await r.json().catch(() => ({}))) as { scope?: string; origin?: string; error?: string; who?: string; issuer?: string; signsAs?: string | null };
470
478
  // the person a platform's pass opened this session for: the page says who is signed in, and the way back to it
471
479
  keepPortal(b.issuer);
472
480
  if (b.who) onWho?.(b.who);
481
+ setSignsAs(typeof b.signsAs === 'string' ? b.signsAs : null);
473
482
  // the World's session lives at its own origin: its pages go there
474
483
  // the host's own answer names the World's origin (a console apart is on another host and port): an http(s) origin only
475
484
  const own = httpOrigin(b.origin)?.origin;
@@ -527,6 +536,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
527
536
  <p className="side-group">Branch</p>
528
537
  {item('branch')}
529
538
  {item('changes')}
539
+ {item('changesets')}
530
540
  {item('activity')}
531
541
  <p className="side-group">Vendors</p>
532
542
  <a href={`${BASE}/${name}/~/canvas`} className="all-screens" onClick={open(['~', 'canvas'])}><span className="mark" aria-hidden="true">▦</span><span>All screens</span></a>
@@ -559,6 +569,7 @@ function WorldView({ name, token, twin, tab, arg, admin, snapshot, onReturn, ope
559
569
  {current === 'activity' && tab === 'trace' ? <Trace name={name} token={token} id={arg ?? ''} /> : null}
560
570
  {current === 'activity' && tab !== 'trace' ? <TimelineTab name={name} token={token} vendors={vendors} /> : null}
561
571
  {current === 'changes' ? <Changes name={name} token={token} /> : null}
572
+ {current === 'changesets' ? <Changesets name={name} token={token} signsAs={signsAs} canWrite={canWrite && !snapshot} rooted={map.state === 'ok' ? map.data.twins.filter((t) => t.root).map((t) => t.twin) : []} {...(arg ? { open: arg } : {})} refused={refused} /> : null}
562
573
  {current === 'branches' ? <Overview name={name} token={token} canWrite={canWrite} branches={hasBranches ? branches : null} opens={opens} onBranchesChanged={() => setClockVersion((v) => v + 1)} refused={refused} /> : null}
563
574
  {current === 'settings' ? <Operations name={name} token={token} admin={admin} canWrite={canWrite} refused={refused} onRemoved={onRemoved} /> : null}
564
575
  </div>
@@ -616,7 +627,7 @@ function BranchPicker({ name, snapshot, branches, opens }: { name: string; snaps
616
627
  {snapshot ? <li><a href={`${BASE}/${name}`} aria-current="page" onClick={(e) => e.preventDefault()}>{short(name)} <span className="mode">as of <At iso={snapshot.at} /></span></a></li> : null}
617
628
  {others.map((b) => (
618
629
  <li key={b.name}>{opens(b.name)
619
- ? <a href={`${BASE}/${b.name}`} onClick={(e) => { e.preventDefault(); go(b.name.split('/')); }}>{short(b.name)} <span className="mode">{b.at?.instant ? <>as of <At iso={b.at.instant} /></> : 'copy'}</span></a>
630
+ ? <a href={`${BASE}/${b.name}`} onClick={(e) => { e.preventDefault(); goBranch(b.name, b.name.split('/')); }}>{short(b.name)} <span className="mode">{b.at?.instant ? <>as of <At iso={b.at.instant} /></> : 'copy'}</span></a>
620
631
  : <span className="mode" title="Its token was handed to whoever made it">{short(b.name)}</span>}</li>
621
632
  ))}
622
633
  </ul>
@@ -869,7 +880,7 @@ function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, r
869
880
  const total = map.state === 'ok' ? map.data.twins.reduce((n, t) => n + t.resources.reduce((m, x) => m + x.count, 0), 0) : 0;
870
881
  // the Branches tab: this World's copies; what it holds per vendor is the Vendors tab's list (kept here for a host
871
882
  // without branches, where this tab is not shown)
872
- if (branches) return <BranchList name={name} token={token} canWrite={canWrite} branches={branches} opens={opens} note={note} remove={remove} />;
883
+ if (branches) return <BranchList name={name} token={token} canWrite={canWrite} branches={branches} opens={opens} note={note} remove={remove} onChanged={onBranchesChanged} refused={refused} />;
873
884
  return (
874
885
  <>
875
886
  <h3>Map</h3>
@@ -904,14 +915,57 @@ function Overview({ name, token, canWrite, branches, opens, onBranchesChanged, r
904
915
  );
905
916
  }
906
917
 
907
- /** A World's branches: copies of it, each as of a moment (a snapshot "as of" is one, short-lived), openable and removable. */
908
- function BranchList({ name, canWrite, branches, opens, note, remove }: { name: string; token: string; canWrite: boolean; branches: Load<{ branches: Branch[] }>; opens: (name: string) => boolean; note: string | null; remove: (b: string) => Promise<void> }): React.JSX.Element {
909
- // as Neon's Branches page: what a branch is for, then every branch with its parent, the moment it copies and its life
918
+ /** A World's branches, as a database's are (Neon's Branches page): each a copy of it as of a moment, made here (as of now
919
+ * or a moment you pick, for as long as you say), opened read-only in this tab, compared with its parent (what it changed
920
+ * since it branched), reset to its parent as it is now, or removed. Each is the World's own branches door. */
921
+ function BranchList({ name, token, canWrite, branches, opens, note, remove, onChanged, refused }: { name: string; token: string; canWrite: boolean; branches: Load<{ branches: Branch[] }>; opens: (name: string) => boolean; note: string | null; remove: (b: string) => Promise<void>; onChanged: () => void; refused: (r: Response) => void }): React.JSX.Element {
910
922
  const short = (n: string): string => n.split('/').pop() ?? n;
911
923
  const list = branches.state === 'ok' ? branches.data.branches : [];
924
+ const [making, setMaking] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState<string | null>(null);
925
+ const [label, setLabel] = useState(''); const [when, setWhen] = useState<'now' | 'at'>('now'); const [at, setAt] = useState(''); const [hours, setHours] = useState('24');
926
+ // made here: kept with its tokens so it opens in this tab; not `made` (a snapshot's "Return to live" never removes it: it lives as long as it was given)
927
+ const make = async (body: Record<string, unknown>, opened: string): Promise<{ name: string } | string> => {
928
+ const r = await door(token, `/-/${name}/branches`, { method: 'POST', body: JSON.stringify(body) }).catch(() => null);
929
+ if (!r) return `${ORIGIN} did not answer`;
930
+ if (!r.ok) { refused(r); return await failure(r); }
931
+ const b = (await r.json()) as { name: string; token?: string; readToken: string; expiresAt: string | null; origin?: string };
932
+ keepSnapshot({ name: b.name, parent: name, token: b.token ?? '', readToken: b.readToken, at: opened, expiresAt: b.expiresAt ?? null, parentOrigin: ORIGIN, ...(b.origin ? { origin: b.origin } : {}) });
933
+ return b;
934
+ };
935
+ const create = async (e: React.FormEvent): Promise<void> => {
936
+ e.preventDefault(); setError(null);
937
+ const instant = when === 'at' ? new Date(at) : null;
938
+ if (instant && Number.isNaN(instant.getTime())) { setError('Pick a date and time, or make it as of now.'); return; }
939
+ if (instant && instant.getTime() > Date.now()) { setError('A branch is made as of now or a moment before it.'); return; }
940
+ const ttl = Math.round(Number(hours) * 3600);
941
+ setBusy(true);
942
+ const b = await make({ ttl, ...(instant ? { at: { instant: instant.toISOString() } } : { live: true }), ...(label.trim() ? { label: label.trim() } : {}) }, (instant ?? new Date()).toISOString());
943
+ setBusy(false);
944
+ if (typeof b === 'string') { setError(b); return; }
945
+ setMaking(false); setLabel(''); toast(`Made ${short(b.name)}`); onChanged();
946
+ };
947
+ const reset = async (b: Branch): Promise<void> => {
948
+ if (!(await confirmAct({ title: `Reset ${short(b.name)} to ${short(name)}?`, body: `A fresh copy of ${short(name)} as it is now replaces it, with the same label and the time it had left; what was written to it goes. The copy has a new address and new keys: an app, a CI job or a pull request's preview that holds this branch's must be given the new one.`, action: 'Reset branch', danger: true }))) return;
949
+ const left = b.expiresAt ? Math.max(3600, Math.round((Date.parse(b.expiresAt) - Date.now()) / 1000)) : 86_400;
950
+ // a branch is named `<parent>-<label>-<4 hex>`: its label is what lies between
951
+ const own = short(b.name).startsWith(`${short(name)}-`) ? short(b.name).slice(short(name).length + 1) : '';
952
+ const labelOf = /^([a-z0-9][a-z0-9-]*)-[0-9a-f]{4}$/.exec(own)?.[1];
953
+ const fresh = await make({ ttl: left, live: true, ...(labelOf && !labelOf.startsWith('at-') ? { label: labelOf } : {}) }, new Date().toISOString());
954
+ if (typeof fresh === 'string') { toast(`Reset ${short(b.name)}: ${fresh}`, 'error'); return; }
955
+ const r = await door(token, `/-/${name}/branches/${b.name}`, { method: 'DELETE' }).catch(() => null);
956
+ if (r?.ok) forgetSnapshot(b.name);
957
+ toast(r?.ok ? `Reset: ${short(fresh.name)} is ${short(name)} as it is now` : `Made ${short(fresh.name)}; ${short(b.name)} was not removed`); onChanged();
958
+ };
959
+ const menu = (b: Branch): Array<{ label: string; onSelect: () => void; danger?: boolean }> => [
960
+ ...(opens(b.name) ? [{ label: 'Compare with parent', onSelect: () => goBranch(b.name, [...b.name.split('/'), '~', 'changes']) }] : []),
961
+ ...(canWrite ? [{ label: 'Reset to parent…', onSelect: () => void reset(b) }, { label: 'Delete…', danger: true, onSelect: () => void remove(b.name) }] : []),
962
+ ];
912
963
  return (
913
964
  <div aria-labelledby="branches">
914
- <p className="lede">Branch this World to look at it as it was, or to change it without touching <b>{short(name)}</b>. {canWrite ? '“View an earlier moment…” above makes one as the World was then.' : ''}</p>
965
+ <div className="branches-head">
966
+ <p className="lede">Branch this World to look at it as it was, or to change it without touching <b>{short(name)}</b>. A branch made here opens read-only in this tab; its parent removes it.</p>
967
+ {canWrite ? <button type="button" className="primary" onClick={() => { setError(null); setAt(localInput(new Date().toISOString())); setMaking(true); }}>New branch</button> : null}
968
+ </div>
915
969
  {note ? <p role="status">{note}</p> : null}
916
970
  <Pending load={branches} what="the branches" />
917
971
  {branches.state === 'ok' ? (
@@ -920,24 +974,39 @@ function BranchList({ name, canWrite, branches, opens, note, remove }: { name: s
920
974
  <thead><tr><th id="branches">Branch</th><th>Parent</th><th>As of</th><th>Made</th><th>Goes away</th><th /></tr></thead>
921
975
  <tbody>
922
976
  <tr><td><b>{short(name)}</b> <span className="badge">default</span></td><td className="mode">—</td><td className="mode">live</td><td className="mode">—</td><td className="mode">kept</td><td /></tr>
923
- {list.map((b) => (
924
- <tr key={b.name}>
925
- <td><b>{short(b.name)}</b></td>
926
- <td>{short(name)}</td>
927
- <td>{b.at?.instant ? <At iso={b.at.instant} /> : 'when it was made'}</td>
928
- <td><At iso={b.createdAt} /></td>
929
- <td>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</td>
930
- <td className="num">
931
- {opens(b.name) ? <button type="button" aria-label={`Open ${b.name}`} onClick={() => go(b.name.split('/'))}>Open</button> : <span className="mode" title="Its token was handed to whoever made it">opened by whoever made it</span>}{' '}
932
- {canWrite ? <button type="button" aria-label={`Delete ${b.name}`} onClick={() => void remove(b.name)}>Delete</button> : null}
933
- </td>
934
- </tr>
935
- ))}
977
+ {list.map((b) => {
978
+ const items = menu(b);
979
+ return (
980
+ <tr key={b.name}>
981
+ <td><b>{short(b.name)}</b></td>
982
+ <td>{short(name)}</td>
983
+ <td>{b.at?.instant ? <At iso={b.at.instant} /> : 'when it was made'}</td>
984
+ <td><At iso={b.createdAt} /></td>
985
+ <td>{b.expiresAt ? <At iso={b.expiresAt} /> : 'kept'}</td>
986
+ <td className="num row-actions">
987
+ {opens(b.name) ? <button type="button" aria-label={`Open ${b.name}`} onClick={() => goBranch(b.name, b.name.split('/'))}>Open</button> : <span className="mode" title="Its token was handed to whoever made it">opened by whoever made it</span>}
988
+ {items.length ? <Menu label={`More for ${short(b.name)}`} items={items} /> : null}
989
+ </td>
990
+ </tr>
991
+ );
992
+ })}
936
993
  </tbody>
937
994
  </table>
938
995
  {list.length === 0 ? <p className="mode pad">No branches yet.</p> : null}
939
996
  </div>
940
997
  ) : null}
998
+ <Dialog open={making} onClose={() => { if (!busy) setMaking(false); }} title={`New branch of ${short(name)}`} footer={<><button type="button" onClick={() => setMaking(false)} disabled={busy}>Cancel</button><button type="submit" form="new-branch" className="primary" disabled={busy}>{busy ? 'Making…' : 'Make branch'}</button></>}>
999
+ <form id="new-branch" onSubmit={(e) => void create(e)} className="stack">
1000
+ <label><span>Name <span className="mode">(optional)</span></span><input name="branchLabel" value={label} onChange={(e) => setLabel(e.target.value.toLowerCase())} placeholder="checkout-flow" pattern="[a-z0-9][a-z0-9-]{0,23}" maxLength={24} /></label>
1001
+ <fieldset className="choice"><legend>As of</legend>
1002
+ <label className="check"><input type="radio" name="branchWhen" checked={when === 'now'} onChange={() => setWhen('now')} /> <span>Now: the World as it is, its clock running on</span></label>
1003
+ <label className="check"><input type="radio" name="branchWhen" checked={when === 'at'} onChange={() => setWhen('at')} /> <span>A moment before now: the World as it was then, its clock stopped there</span></label>
1004
+ {when === 'at' ? <input type="datetime-local" aria-label="As of" value={at} max={localInput(new Date().toISOString())} onChange={(e) => setAt(e.target.value)} /> : null}
1005
+ </fieldset>
1006
+ <label><span>Goes away after</span><select name="branchLife" value={hours} onChange={(e) => setHours(e.target.value)}><option value="1">1 hour</option><option value="24">1 day</option><option value="72">3 days</option><option value="168">7 days</option></select></label>
1007
+ {error ? <p role="alert">{error}</p> : null}
1008
+ </form>
1009
+ </Dialog>
941
1010
  </div>
942
1011
  );
943
1012
  }
@@ -1278,22 +1347,171 @@ function CanvasSurface({ name, twins, session, retry, snapshot, back }: { name:
1278
1347
  }
1279
1348
 
1280
1349
  /** What this World changed since its base — what a changeset would cut — in a compare view, by twin. */
1281
- function Changes({ name, token }: { name: string; token: string }): React.JSX.Element {
1282
- const diff = useDoor<Diff>(token, `/-/${name}/diff`);
1283
- const byTwin = useMemo(() => {
1284
- const m = new Map<string, Entry[]>();
1285
- if (diff.state === 'ok') for (const c of diff.data.changes) m.set(c.twin, [...(m.get(c.twin) ?? []), c.entry]);
1286
- return [...m.entries()];
1287
- }, [diff]);
1350
+ /** A changeset as the World's changesets door answers it: what it does at each vendor, the checks it ran, who signed
1351
+ * which bytes, and whether it went to the real vendors (with a receipt for each change). */
1352
+ type ChangesetView = {
1353
+ name: string; message?: string; narration?: string; createdAt: string; contentHash: string; /** the body's hash as it stands (GET …/changesets) */ currentHash?: string;
1354
+ actions: Array<{ service: string; action: Omit<Entry, 'service' | 'op'> & { op?: string; operation?: string } }>;
1355
+ verification: { at: string; contentHash: string; passed: boolean; results: Array<{ id: string; service: string; passed: boolean; actual?: unknown }>; refusals?: Array<{ service: string; check: string; reason: string }> } | null;
1356
+ approvals: Array<{ principal: string; at: string; contentHash: string; note?: string }>;
1357
+ applied: { at: string; outcome: 'applied' | 'partial' | 'refused'; refusal?: string[]; receipts: Array<{ actionId: string; service: string; status: string; externalId?: string; url?: string; error?: string }> } | null;
1358
+ };
1359
+ /** Its actions as the Changes view shows entries: by vendor. */
1360
+ const changesetGroups = (c: ChangesetView): Array<[string, Entry[]]> => {
1361
+ const m = new Map<string, Entry[]>();
1362
+ for (const a of c.actions) m.set(a.service, [...(m.get(a.service) ?? []), { ...a.action, service: a.service, op: a.action.op ?? a.action.operation ?? '' } as Entry]);
1363
+ return [...m.entries()];
1364
+ };
1365
+ /** Where it stands, in a word, and the state behind the word: a verification or an approval of other bytes is stale. */
1366
+ function changesetStatus(c: ChangesetView): { label: string; tone: 'done' | 'good' | 'warn' | 'bad' | 'plain' } {
1367
+ if (c.applied) return c.applied.outcome === 'applied' ? { label: 'Deployed', tone: 'done' } : c.applied.outcome === 'partial' ? { label: 'Partly deployed', tone: 'warn' } : { label: 'Deploy refused', tone: 'bad' };
1368
+ const now = c.currentHash ?? c.contentHash;
1369
+ const signed = c.approvals.filter((a) => a.contentHash === now).length;
1370
+ const checked = c.verification && c.verification.contentHash === now ? c.verification : null;
1371
+ if (checked && !checked.passed) return { label: 'Checks failed', tone: 'bad' };
1372
+ if (signed > 0) return { label: `Approved by ${signed}`, tone: 'good' };
1373
+ if (checked) return { label: 'Verified', tone: 'good' };
1374
+ return { label: 'Not verified', tone: 'plain' };
1375
+ }
1376
+
1377
+ /** THE CHANGESETS: what this World would do, cut into reviewable pieces, listed as a repository lists pull requests;
1378
+ * each one's page reviews it (its changes by vendor, its checks, its approvals) and, where the World has a real root,
1379
+ * deploys it. Every button is the World's own door; a refusal is the door's words. */
1380
+ function Changesets({ name, token, signsAs, canWrite, rooted, open: opened, refused }: { name: string; token: string; signsAs: string | null; canWrite: boolean; rooted: string[]; open?: string; refused: (r: Response) => void }): React.JSX.Element {
1381
+ const [version, setVersion] = useState(0);
1382
+ const list = useDoor<{ changesets: ChangesetView[] }>(token, `/-/${name}/changesets`, version);
1383
+ const at = (c: string) => (e: React.MouseEvent): void => { e.preventDefault(); go([...name.split('/'), '~', 'changesets', c]); };
1384
+ if (opened) {
1385
+ const c = list.state === 'ok' ? list.data.changesets.find((x) => x.name === opened) : undefined;
1386
+ return (
1387
+ <>
1388
+ <p className="crumbs-line"><a href={`${BASE}/${name}/~/changesets`} onClick={(e) => { e.preventDefault(); go([...name.split('/'), '~', 'changesets']); }}>← Changesets</a></p>
1389
+ <Pending load={list} what="the changesets" />
1390
+ {list.state === 'ok' && !c ? <p className="mode">This World has no changeset named {opened}.</p> : null}
1391
+ {c ? <ChangesetPage name={name} token={token} signsAs={signsAs} c={c} canWrite={canWrite} rooted={rooted} refused={refused} onChanged={() => setVersion((v) => v + 1)} /> : null}
1392
+ </>
1393
+ );
1394
+ }
1395
+ const rows = list.state === 'ok' ? [...list.data.changesets].sort((a, b) => b.createdAt.localeCompare(a.createdAt)) : [];
1288
1396
  return (
1289
1397
  <>
1290
- <Pending load={diff} what="the changes" />
1291
- {diff.state === 'ok' ? (
1398
+ <p className="lede">A changeset is what this World changed, cut into a piece a person reviews: what it does at each vendor, the World's checks over it, and who approved it. Where the World has a real account behind a vendor, an approved changeset deploys there, with a receipt for each change.</p>
1399
+ <Pending load={list} what="the changesets" />
1400
+ {list.state === 'ok' && rows.length === 0 ? (
1401
+ <Empty title="No changesets yet">
1402
+ <p>Cut one from what your app did in this World: <code>volter world changeset -m "What it does and why"</code>. Pushed to a shared World, it shows up here for review.</p>
1403
+ </Empty>
1404
+ ) : null}
1405
+ {rows.length > 0 ? (
1406
+ <div className="block">
1407
+ <table className="dense" aria-label="Changesets">
1408
+ <thead><tr><th>Changeset</th><th>Changes</th><th>Made</th><th>Status</th></tr></thead>
1409
+ <tbody>{rows.map((c) => {
1410
+ const s = changesetStatus(c);
1411
+ const counts = changesetGroups(c).map(([v, es]) => `${es.length} at ${vendorName(v)}`).join(' · ');
1412
+ return (
1413
+ <tr key={c.name}>
1414
+ <td><a href={`${BASE}/${name}/~/changesets/${c.name}`} onClick={at(c.name)}><b>{c.message ?? c.name}</b></a>{c.message ? <span className="mode mono"> {c.name}</span> : null}</td>
1415
+ <td className="mode">{counts || 'nothing'}</td>
1416
+ <td className="mode"><At iso={c.createdAt} /></td>
1417
+ <td><span className={`badge cs-${s.tone}`}>{s.label}</span></td>
1418
+ </tr>
1419
+ );
1420
+ })}</tbody>
1421
+ </table>
1422
+ </div>
1423
+ ) : null}
1424
+ </>
1425
+ );
1426
+ }
1427
+
1428
+ /** One changeset, reviewed: its words, its changes by vendor, its checks, its approvals and its receipts; Verify,
1429
+ * Approve (signed as the person this session is for) and Deploy where they apply, each saying why when it does not. */
1430
+ function ChangesetPage({ name, token, signsAs, c, canWrite, rooted, refused, onChanged }: { name: string; token: string; signsAs: string | null; c: ChangesetView; canWrite: boolean; rooted: string[]; refused: (r: Response) => void; onChanged: () => void }): React.JSX.Element {
1431
+ const [busy, setBusy] = useState<string | null>(null);
1432
+ const [approving, setApproving] = useState(false); const [note, setNote] = useState(''); const [as, setAs] = useState('');
1433
+ const s = changesetStatus(c);
1434
+ const groups = changesetGroups(c);
1435
+ const touches = groups.map(([v]) => v);
1436
+ const deployable = touches.some((v) => rooted.includes(v));
1437
+ const current = (h: string): boolean => h === (c.currentHash ?? c.contentHash);
1438
+ const act = async (what: string, path: string, body?: unknown, done?: string): Promise<boolean> => {
1439
+ setBusy(what);
1440
+ try {
1441
+ const r = await door(token, path, { method: 'POST', ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
1442
+ if (!r.ok) { refused(r); toast(`${what}: ${await failure(r)}`, 'error'); return false; }
1443
+ if (done) toast(done);
1444
+ onChanged(); return true;
1445
+ } catch (e) { toast(`${what}: the World did not answer (${e instanceof Error ? e.message : String(e)})`, 'error'); return false; } finally { setBusy(null); }
1446
+ };
1447
+ const verify = (): void => { void act('Verify', `/-/${name}/changesets/${c.name}/verify`, undefined, 'Verified: the checks ran over it'); };
1448
+ const approve = async (e: React.FormEvent): Promise<void> => { e.preventDefault(); if (await act('Approve', `/-/${name}/changesets/${c.name}/approve`, { ...(note.trim() ? { note: note.trim() } : {}), ...(signsAs === null && as.trim() ? { as: as.trim() } : {}) }, 'Approved')) { setApproving(false); setNote(''); } };
1449
+ const deploy = async (): Promise<void> => {
1450
+ if (!(await confirmAct({ title: `Deploy ${c.name} to the real vendors?`, body: <>It performs its {c.actions.length} change{c.actions.length === 1 ? '' : 's'} against the real account{touches.filter((v) => rooted.includes(v)).length === 1 ? '' : 's'} behind {listed(touches.filter((v) => rooted.includes(v)).map(vendorName))}. This cannot be taken back here.</>, action: 'Deploy', danger: true, typed: c.name }))) return;
1451
+ void act('Deploy', `/-/${name}/deploy/${c.name}`, { confirm: c.name }, 'Deployed: the receipts are below');
1452
+ };
1453
+ // why a button is off, in words: the reader's scope, what already happened, or the World's own rule
1454
+ const offWhy = !canWrite ? 'This session reads the World; it changes nothing' : c.applied ? 'Already deployed' : null;
1455
+ return (
1456
+ <div className="changeset" aria-labelledby="changeset-title">
1457
+ <div className="changeset-head">
1458
+ <div>
1459
+ <h3 id="changeset-title">{c.message ?? c.name} <span className={`badge cs-${s.tone}`}>{s.label}</span></h3>
1460
+ <p className="mode"><code>{c.name}</code> · made <At iso={c.createdAt} /> · {c.actions.length} change{c.actions.length === 1 ? '' : 's'} at {listed(touches.map(vendorName)) || 'no vendor'}</p>
1461
+ </div>
1462
+ <div className="changeset-actions">
1463
+ <button type="button" onClick={verify} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Replay it in a throwaway World and run the World\'s checks'}>{busy === 'Verify' ? 'Verifying…' : 'Verify'}</button>
1464
+ <button type="button" onClick={() => setApproving(true)} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Sign its current contents as yourself'}>Approve…</button>
1465
+ {deployable ? <button type="button" className="primary" onClick={() => void deploy()} disabled={Boolean(offWhy) || busy !== null} title={offWhy ?? 'Perform it against the real accounts behind this World'}>{busy === 'Deploy' ? 'Deploying…' : 'Deploy…'}</button> : null}
1466
+ </div>
1467
+ </div>
1468
+ {offWhy && !c.applied ? <p className="mode">{offWhy}.</p> : null}
1469
+ {!deployable && !c.applied ? <p className="mode">It stays in this World: no vendor it changes has a real account behind it here (a shared World with a root deploys; see Settings › Real services).</p> : null}
1470
+ {c.narration ? <p className="narration">{c.narration}</p> : null}
1471
+
1472
+ <h4>Changes</h4>
1473
+ {groups.length === 0 ? <p className="mode">It changes nothing.</p> : <ByVendor groups={groups} idPrefix={`cs-${c.name}`} />}
1474
+
1475
+ <h4>Checks</h4>
1476
+ {!c.verification ? <p className="mode">Not verified yet. Verify replays it in a throwaway World and runs this World's checks over the result.</p> : (
1477
+ <div className={current(c.verification.contentHash) ? '' : 'stale'}>
1478
+ <p>{c.verification.passed ? '✓ Passed' : '✗ Failed'} · <At iso={c.verification.at} />{current(c.verification.contentHash) ? null : <span className="mode"> · on an earlier version of it: verify again</span>}</p>
1479
+ {c.verification.results.length ? <ul className="checks">{c.verification.results.map((r) => <li key={r.id}>{r.passed ? '✓' : '✗'} <code>{r.id}</code> <span className="mode">{vendorName(r.service)}</span></li>)}</ul> : null}
1480
+ {c.verification.refusals?.length ? <ul className="checks">{c.verification.refusals.map((r, i) => <li key={i}>✗ <code>{r.check}</code> <span className="mode">{vendorName(r.service)}: {r.reason}</span></li>)}</ul> : null}
1481
+ </div>
1482
+ )}
1483
+
1484
+ <h4>Approvals</h4>
1485
+ {c.approvals.length === 0 ? <p className="mode">No one has approved it.</p> : (
1486
+ <ul className="approvals">{c.approvals.map((a, i) => <li key={i}><b>{a.principal}</b> <span className="mode"><At iso={a.at} />{current(a.contentHash) ? '' : ' · signed an earlier version'}</span>{a.note ? <div className="mode">“{a.note}”</div> : null}</li>)}</ul>
1487
+ )}
1488
+
1489
+ {c.applied ? (
1292
1490
  <>
1293
- <p className="mode">What this World changed since it started{diff.data.base.at ? <> (<At iso={diff.data.base.at} />, <code>{diff.data.base.id}</code>)</> : <> (<code>{diff.data.base.id}</code>)</>}: {diff.data.changes.length} change{diff.data.changes.length === 1 ? '' : 's'} at {byTwin.length} vendor{byTwin.length === 1 ? '' : 's'}. A changeset takes these to the real services.</p>
1294
- {byTwin.length === 0 ? <p className="empty">No changes since {diff.data.base.id}{diff.data.base.at ? <> (<At iso={diff.data.base.at} />)</> : null}.</p> : byTwin.map(([t, entries]) => (
1295
- <div key={t} className="compare" role="group" aria-labelledby={`changes-${t}`}>
1296
- <h3 id={`changes-${t}`} className="vendor-cell"><Mark vendor={t} />{vendorName(t)} <span className="mode">· {entries.length} change{entries.length === 1 ? '' : 's'}</span></h3>
1491
+ <h4>Deployed</h4>
1492
+ <p>{c.applied.outcome === 'applied' ? 'Every change reached the real vendors' : c.applied.outcome === 'partial' ? 'Some changes reached the real vendors' : 'The deploy was refused'} · <At iso={c.applied.at} /></p>
1493
+ {c.applied.refusal?.length ? <ul className="checks">{c.applied.refusal.map((r, i) => <li key={i} className="mode">{r}</li>)}</ul> : null}
1494
+ {c.applied.receipts.length ? <ul className="receipts">{c.applied.receipts.map((r) => <li key={r.actionId}><span className={r.status === 'failed' ? 'badge cs-bad' : 'badge cs-done'}>{r.status}</span> {vendorName(r.service)} {r.url ? <a href={r.url} target="_blank" rel="noopener noreferrer">{r.externalId ?? r.url}</a> : r.externalId ? <code>{r.externalId}</code> : null}{r.error ? <span className="mode"> · {r.error}</span> : null}</li>)}</ul> : null}
1495
+ </>
1496
+ ) : null}
1497
+
1498
+ <Dialog open={approving} onClose={() => { if (!busy) setApproving(false); }} title={`Approve ${c.name}`} footer={<><button type="button" onClick={() => setApproving(false)} disabled={busy !== null}>Cancel</button><button type="submit" form="approve-changeset" className="primary" disabled={busy !== null}>{busy === 'Approve' ? 'Approving…' : 'Approve'}</button></>}>
1499
+ <form id="approve-changeset" onSubmit={(e) => void approve(e)} className="stack">
1500
+ <p className="mode">You sign its contents as they stand now{signsAs ? <>, recorded as <b>{signsAs}</b></> : null}. A later change to it shows your approval as for an earlier version.</p>
1501
+ {signsAs === null ? <label><span>Approving as</span><input name="approveAs" value={as} onChange={(e) => setAs(e.target.value)} maxLength={80} required placeholder="your name or email" autoComplete="off" /><span className="mode">This World was opened with its token, which names no person: the approval records the name you give.</span></label> : null}
1502
+ <label><span>Note <span className="mode">(optional)</span></span><textarea name="approveNote" value={note} onChange={(e) => setNote(e.target.value)} rows={3} maxLength={500} /></label>
1503
+ </form>
1504
+ </Dialog>
1505
+ </div>
1506
+ );
1507
+ }
1508
+
1509
+ /** What a set of entries does at each vendor, grouped by vendor, runs of three alike folded: the Changes view's and a
1510
+ * changeset's page's one rendering. */
1511
+ function ByVendor({ groups, idPrefix }: { groups: Array<[string, Entry[]]>; idPrefix: string }): React.JSX.Element {
1512
+ return (<>{groups.map(([t, entries]) => (
1513
+ <div key={t} className="compare" role="group" aria-labelledby={`${idPrefix}-${t}`}>
1514
+ <h3 id={`${idPrefix}-${t}`} className="vendor-cell"><Mark vendor={t} />{vendorName(t)} <span className="mode">· {entries.length} change{entries.length === 1 ? '' : 's'}</span></h3>
1297
1515
  <ul>{runsOf(entries).map((run) => run.length >= 3 ? (
1298
1516
  <li key={run[0]!.id}>
1299
1517
  <details>
@@ -1310,7 +1528,23 @@ function Changes({ name, token }: { name: string; token: string }): React.JSX.El
1310
1528
  </li>
1311
1529
  )))}</ul>
1312
1530
  </div>
1313
- ))}
1531
+ ))}</>);
1532
+ }
1533
+
1534
+ function Changes({ name, token }: { name: string; token: string }): React.JSX.Element {
1535
+ const diff = useDoor<Diff>(token, `/-/${name}/diff`);
1536
+ const byTwin = useMemo(() => {
1537
+ const m = new Map<string, Entry[]>();
1538
+ if (diff.state === 'ok') for (const c of diff.data.changes) m.set(c.twin, [...(m.get(c.twin) ?? []), c.entry]);
1539
+ return [...m.entries()];
1540
+ }, [diff]);
1541
+ return (
1542
+ <>
1543
+ <Pending load={diff} what="the changes" />
1544
+ {diff.state === 'ok' ? (
1545
+ <>
1546
+ <p className="mode">What this World changed since it started{diff.data.base.at ? <> (<At iso={diff.data.base.at} />, <code>{diff.data.base.id}</code>)</> : <> (<code>{diff.data.base.id}</code>)</>}: {diff.data.changes.length} change{diff.data.changes.length === 1 ? '' : 's'} at {byTwin.length} vendor{byTwin.length === 1 ? '' : 's'}. A changeset takes these to the real services.</p>
1547
+ {byTwin.length === 0 ? <p className="empty">No changes since {diff.data.base.id}{diff.data.base.at ? <> (<At iso={diff.data.base.at} />)</> : null}.</p> : <ByVendor groups={byTwin} idPrefix="changes" />}
1314
1548
  </>
1315
1549
  ) : null}
1316
1550
  </>