@quolu/lattice 0.55.1 → 0.57.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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Bridge hub public landing — the product face of lattice.kitepon.dev.
3
+ *
4
+ * Pure rendering only: no I/O, no clock, no store access. The hub server
5
+ * (`bridge-hub-server.mjs`) reads the registry, projects the public view,
6
+ * applies the operator's visibility file, and passes the result here.
7
+ *
8
+ * Design canon (ADR 0164): the page follows the kitepon.dev brand canon
9
+ * (RootSitePromotion docs/color-system.md, docs/identity-system.md) — not
10
+ * the loopback dashboard's look. Constraints baked in:
11
+ * - No arrow glyphs as link decoration (identity-system.md "Links").
12
+ * - No external resources: CSP stays `default-src 'none'`, the diagram is
13
+ * inline SVG, fonts are the system stack.
14
+ * - No white small text on #EF8D32; deep orange for small labels.
15
+ * - No noindex: this page exists to be found (ADR 0164 Decision 5).
16
+ */
17
+
18
+ export const BRIDGE_HUB_LANDING_LANGUAGES = Object.freeze(['ja', 'en']);
19
+
20
+ const ORIGIN = 'https://lattice.kitepon.dev';
21
+
22
+ // One table for both languages: a missing translation is a visible
23
+ // structural gap here, never a silent fallback at a call site.
24
+ const LANDING_COPY = Object.freeze({
25
+ ja: Object.freeze({
26
+ htmlLang: 'ja',
27
+ canonicalPath: '/',
28
+ switchHref: '/en/',
29
+ switchLabel: 'English',
30
+ title: 'Lattice — 並列開発のschedulability compiler',
31
+ description: 'コードベースの実際の変更境界を解析し、開発タスクの並列実行可否を判定するLatticeの製品紹介と、公開中のライブ工程表。',
32
+ eyebrow: '公開 / 特許出願済み',
33
+ heroLead: 'コードベースの実際の変更境界を解析し、複数の開発タスクを並列実行できるか判定するschedulability compiler。競合する場合は境界を分割し、計画を再コンパイルする。',
34
+ howTitle: 'Latticeがしていること',
35
+ howCards: [
36
+ { title: '境界を観測する', body: 'コードの実際の変更境界——ファイル・シンボル・依存の交差——を、索引済みのコード知識グラフから読み取ります。宣言や推測ではなく、実態から始めます。' },
37
+ { title: '並列可能を証明する', body: 'タスク同士の書込範囲が交差しないことを検証し、同時に走らせてよい組を確定します。並列化は雰囲気ではなく、証明の結果です。' },
38
+ { title: '分割して、再コンパイル', body: 'タスクが衝突したら、境界を切り分けて計画を再コンパイルします。工程は止まらず、次に走れる形へ組み直されます。' },
39
+ ],
40
+ diagramLabel: '衝突した2つのタスクの境界を分割し、並列の2経路へ再コンパイルする図',
41
+ liveTitle: 'ライブ工程表',
42
+ liveCaption: 'この一覧は、Latticeが管理している実プロジェクトの公開データから、いまこの瞬間に生成されています。開くと各工程の現在地が見られます。この一覧自体が動作デモです。',
43
+ hostPrefix: '配信元',
44
+ statusOnline: 'オンライン',
45
+ statusOffline: 'オフライン',
46
+ empty: 'いまは公開中の端末がありません。少し待ってから再読み込みしてください。',
47
+ ctaGithub: 'GitHubで見る',
48
+ ctaRoot: 'kitepon.devへ戻る',
49
+ rootHref: 'https://kitepon.dev/',
50
+ footerNote: 'kitepon.dev の開発工程を、Lattice自身で可視化しています。',
51
+ footerNavLabel: '関連リンク',
52
+ }),
53
+ en: Object.freeze({
54
+ htmlLang: 'en',
55
+ canonicalPath: '/en/',
56
+ switchHref: '/',
57
+ switchLabel: '日本語',
58
+ title: 'Lattice — a schedulability compiler for parallel development',
59
+ description: 'Product page and live public plans for Lattice, a schedulability compiler that proves which development tasks can run in parallel.',
60
+ eyebrow: 'PUBLIC / PATENT PENDING',
61
+ heroLead: 'A schedulability compiler that analyzes real code-change boundaries and proves which development tasks can run in parallel. When tasks collide, it splits the boundary and recompiles the plan.',
62
+ howTitle: 'What Lattice does',
63
+ howCards: [
64
+ { title: 'Observe real boundaries', body: 'It reads the actual change boundaries of the code — files, symbols, crossing dependencies — from an indexed knowledge graph. It starts from reality, not from declarations.' },
65
+ { title: 'Prove parallelism', body: 'It verifies that tasks touch non-overlapping write ranges and pins down which of them may run at the same time. Parallelism is a proof result, not a mood.' },
66
+ { title: 'Split and recompile', body: 'When tasks collide, it splits the seam and recompiles the plan. The work does not stop; it is reshaped into what can run next.' },
67
+ ],
68
+ diagramLabel: 'Diagram: two colliding tasks are split at the boundary and recompiled into two parallel routes',
69
+ liveTitle: 'Live plans',
70
+ liveCaption: 'This list is compiled at this very moment from public data of real projects managed by Lattice itself. Open one to see where the work stands. The list is the demo.',
71
+ hostPrefix: 'served by',
72
+ statusOnline: 'ONLINE',
73
+ statusOffline: 'OFFLINE',
74
+ empty: 'No terminals are publishing right now. Check back in a moment.',
75
+ ctaGithub: 'View on GitHub',
76
+ ctaRoot: 'Back to kitepon.dev',
77
+ rootHref: 'https://kitepon.dev/en',
78
+ footerNote: 'The development of kitepon.dev, made visible by Lattice itself.',
79
+ footerNavLabel: 'Related links',
80
+ }),
81
+ });
82
+
83
+ const GITHUB_URL = 'https://github.com/kitepon-rgb/Lattice';
84
+
85
+ export function escapeHtml(value) {
86
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;')
87
+ .replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
88
+ }
89
+
90
+ // Brand-canon tokens (RootSitePromotion docs/color-system.md, 2026-07-27).
91
+ // Paper/White carry the page; Ink carries text; Orange is confined to
92
+ // eyebrow/labels/pills/CTA; Cobalt to the diagram and hover borders.
93
+ const LANDING_STYLE = `:root{color-scheme:light;--paper:#f8f5ef;--white:#fffef9;--ink:#111b35;--ink-soft:rgba(17,27,53,.62);--line:rgba(17,27,53,.14);--orange:#ef8d32;--orange-strong:#c65300;--orange-deep:#a84400;--orange-soft:#fbe5d2;--cobalt:#2149aa}
94
+ *{box-sizing:border-box}
95
+ body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.8 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}
96
+ .shell{max-width:960px;margin:0 auto;padding:28px 22px 48px}
97
+ .brand{display:flex;align-items:center;gap:9px;padding-bottom:22px;border-bottom:1px solid var(--line);font-size:.88rem}
98
+ .brand a{color:var(--ink);font-weight:800;text-decoration:none}
99
+ .brand a:hover{color:var(--orange-strong)}
100
+ .brand .sep{color:var(--ink-soft)}
101
+ .brand .lang{margin-left:auto;font-weight:700;color:var(--ink-soft)}
102
+ .brand .lang:hover{color:var(--orange-strong)}
103
+ .hero{padding:58px 0 40px}
104
+ .eyebrow{margin:0 0 10px;color:var(--orange-deep);font-size:.72rem;font-weight:800;letter-spacing:.18em}
105
+ h1{margin:0 0 16px;font-size:clamp(2.4rem,7vw,4rem);line-height:1.12;letter-spacing:-.04em}
106
+ .lead{max-width:640px;margin:0;font-size:1.06rem;color:var(--ink)}
107
+ .how{margin:14px 0 0;padding:30px 26px 34px;border-radius:16px;background:var(--orange-soft)}
108
+ h2{margin:0 0 18px;font-size:1.28rem;letter-spacing:-.01em}
109
+ .cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin:0;padding:0;list-style:none}
110
+ .cards li{padding:18px 18px 20px;border:1px solid var(--line);border-radius:12px;background:var(--white)}
111
+ .cards h3{margin:0 0 8px;font-size:.98rem}
112
+ .cards p{margin:0;font-size:.86rem;line-height:1.75;color:var(--ink-soft)}
113
+ .diagram{display:block;margin:26px auto 0;max-width:520px;width:100%;height:auto}
114
+ .live{padding:44px 0 8px}
115
+ .live-caption{max-width:640px;margin:0 0 22px;font-size:.92rem;color:var(--ink-soft)}
116
+ .plans{display:grid;gap:12px;margin:0;padding:0;list-style:none}
117
+ .plans a{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:6px 20px;padding:16px 20px;border:1px solid var(--line);border-radius:12px;color:inherit;background:var(--white);text-decoration:none}
118
+ .plans a:hover{border-color:var(--cobalt);transform:translateY(-1px)}
119
+ .plans a:focus-visible{outline:2px solid var(--cobalt);outline-offset:2px}
120
+ .plans strong{font-size:1.04rem}
121
+ .plans .host{grid-column:1;font-size:.76rem;color:var(--ink-soft)}
122
+ .plans .pill{grid-column:2;grid-row:1/span 2;justify-self:end;padding:3px 10px;border-radius:999px;font-size:.72rem;font-weight:800;letter-spacing:.06em}
123
+ .pill.online{color:var(--orange-strong);background:var(--orange-soft)}
124
+ .pill.offline{color:var(--ink-soft);background:rgba(17,27,53,.07)}
125
+ .empty{padding:18px 20px;border:1px dashed var(--line);border-radius:12px;color:var(--ink-soft)}
126
+ .cta{display:flex;flex-wrap:wrap;gap:12px;padding:36px 0 44px}
127
+ .cta a{display:inline-block;padding:12px 22px;border-radius:999px;font-weight:800;font-size:.94rem;text-decoration:none}
128
+ .cta .primary{color:var(--white);background:var(--orange-strong)}
129
+ .cta .primary:hover{background:var(--orange-deep)}
130
+ .cta .secondary{color:var(--ink);border:1px solid var(--line);background:var(--white)}
131
+ .cta .secondary:hover{border-color:var(--orange-strong);color:var(--orange-strong)}
132
+ .cta a:focus-visible{outline:2px solid var(--cobalt);outline-offset:2px}
133
+ .footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;padding-top:20px;border-top:1px solid var(--line);color:var(--ink-soft);font-size:.82rem}
134
+ .footer a{color:var(--ink);font-weight:700;text-decoration:none}
135
+ .footer a:hover{color:var(--orange-strong)}
136
+ .footer nav{display:flex;gap:18px}
137
+ @media(max-width:720px){.cards{grid-template-columns:1fr}}
138
+ @media(max-width:560px){.shell{padding:20px 16px 36px}.hero{padding:44px 0 30px}.how{padding:22px 18px 26px}.plans a{padding:14px 16px}.footer{display:block}.footer nav{margin-top:10px}}`;
139
+
140
+ // Collide -> split -> parallel, in brand geometry: Cobalt trajectories,
141
+ // Discovery Orange nodes. Inline so the CSP never needs img-src.
142
+ function landingDiagram(label) {
143
+ return `<svg class="diagram" viewBox="0 0 520 150" role="img" aria-label="${escapeHtml(label)}" xmlns="http://www.w3.org/2000/svg">`
144
+ + `<path d="M20 45 C 90 45 130 72 180 75" fill="none" stroke="#2149aa" stroke-width="2"/>`
145
+ + `<path d="M20 105 C 90 105 130 78 180 75" fill="none" stroke="#2149aa" stroke-width="2"/>`
146
+ + `<circle cx="180" cy="75" r="7" fill="#ef8d32"/>`
147
+ + `<path d="M187 75 C 240 75 260 40 320 40 L 500 40" fill="none" stroke="#2149aa" stroke-width="2"/>`
148
+ + `<path d="M187 75 C 240 75 260 110 320 110 L 500 110" fill="none" stroke="#2149aa" stroke-width="2"/>`
149
+ + `<circle cx="320" cy="40" r="5" fill="#ef8d32"/>`
150
+ + `<circle cx="320" cy="110" r="5" fill="#ef8d32"/>`
151
+ + `<circle cx="500" cy="40" r="5" fill="#ef8d32"/>`
152
+ + `<circle cx="500" cy="110" r="5" fill="#ef8d32"/>`
153
+ + `</svg>`;
154
+ }
155
+
156
+ function landingHead(copy) {
157
+ const canonical = `${ORIGIN}${copy.canonicalPath}`;
158
+ return `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">`
159
+ + `<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`
160
+ + `<meta name="description" content="${escapeHtml(copy.description)}">`
161
+ + `<link rel="canonical" href="${canonical}">`
162
+ + `<link rel="alternate" hreflang="ja" href="${ORIGIN}/">`
163
+ + `<link rel="alternate" hreflang="en" href="${ORIGIN}/en/">`
164
+ + `<link rel="alternate" hreflang="x-default" href="${ORIGIN}/">`
165
+ + `<meta property="og:title" content="${escapeHtml(copy.title)}">`
166
+ + `<meta property="og:description" content="${escapeHtml(copy.description)}">`
167
+ + `<meta name="theme-color" content="#f8f5ef">`
168
+ + `<title>${escapeHtml(copy.title)}</title>`
169
+ + `<style>${LANDING_STYLE}</style>`;
170
+ }
171
+
172
+ function landingPlanList(copy, projects, displayNames) {
173
+ if (projects.length === 0) return `<p class="empty">${escapeHtml(copy.empty)}</p>`;
174
+ const rows = projects.map((project) => {
175
+ const href = `/projects/${encodeURIComponent(project.project_id)}/`;
176
+ const online = project.status === 'online';
177
+ const title = displayNames[project.project_id] ?? project.project_id;
178
+ return `<li><a href="${escapeHtml(href)}">`
179
+ + `<strong>${escapeHtml(title)}</strong>`
180
+ + `<span class="pill ${online ? 'online' : 'offline'}">${escapeHtml(online ? copy.statusOnline : copy.statusOffline)}</span>`
181
+ + `<span class="host">${escapeHtml(copy.hostPrefix)}: ${escapeHtml(project.display_name)}</span>`
182
+ + `</a></li>`;
183
+ }).join('');
184
+ return `<ul class="plans">${rows}</ul>`;
185
+ }
186
+
187
+ /**
188
+ * Render the public landing.
189
+ *
190
+ * @param {object} input
191
+ * @param {Array<{project_id: string, display_name: string, status: string}>} input.projects
192
+ * The already-filtered public view (visibility applied by the caller).
193
+ * @param {'ja'|'en'} input.lang
194
+ * @param {Record<string, string>} [input.displayNames]
195
+ * Operator display-name map (public-visibility.json); unmapped ids render as-is.
196
+ */
197
+ export function renderBridgeHubLanding({ projects, lang, displayNames = {} }) {
198
+ const copy = LANDING_COPY[lang];
199
+ if (copy === undefined) throw new RangeError(`unsupported landing language: ${lang}`);
200
+ const cards = copy.howCards.map((card) =>
201
+ `<li><h3>${escapeHtml(card.title)}</h3><p>${escapeHtml(card.body)}</p></li>`).join('');
202
+ return `<!doctype html><html lang="${copy.htmlLang}"><head>${landingHead(copy)}</head><body><div class="shell">`
203
+ + `<header class="brand">`
204
+ + `<a href="${escapeHtml(copy.rootHref)}">kitepon.dev</a><span class="sep" aria-hidden="true">/</span><strong>Lattice</strong>`
205
+ + `<a class="lang" href="${copy.switchHref}" lang="${lang === 'ja' ? 'en' : 'ja'}" hreflang="${lang === 'ja' ? 'en' : 'ja'}">${copy.switchLabel}</a>`
206
+ + `</header>`
207
+ + `<main>`
208
+ + `<section class="hero"><p class="eyebrow">${escapeHtml(copy.eyebrow)}</p><h1>Lattice</h1>`
209
+ + `<p class="lead">${escapeHtml(copy.heroLead)}</p></section>`
210
+ + `<section class="how"><h2>${escapeHtml(copy.howTitle)}</h2><ul class="cards">${cards}</ul>`
211
+ + `${landingDiagram(copy.diagramLabel)}</section>`
212
+ + `<section class="live"><h2>${escapeHtml(copy.liveTitle)}</h2>`
213
+ + `<p class="live-caption">${escapeHtml(copy.liveCaption)}</p>`
214
+ + `${landingPlanList(copy, projects, displayNames)}</section>`
215
+ + `<div class="cta">`
216
+ + `<a class="primary" href="${GITHUB_URL}">${escapeHtml(copy.ctaGithub)}</a>`
217
+ + `<a class="secondary" href="${escapeHtml(copy.rootHref)}">${escapeHtml(copy.ctaRoot)}</a>`
218
+ + `</div>`
219
+ + `</main>`
220
+ + `<footer class="footer"><span>${escapeHtml(copy.footerNote)}</span>`
221
+ + `<nav aria-label="${escapeHtml(copy.footerNavLabel)}"><a href="${escapeHtml(copy.rootHref)}">kitepon.dev</a><a href="${GITHUB_URL}">GitHub</a></nav>`
222
+ + `</footer></div></body></html>`;
223
+ }
@@ -98,11 +98,12 @@ function validRegistry(registry) {
98
98
  /**
99
99
  * Apply one registration/heartbeat call to a registry snapshot. Pure: takes the
100
100
  * current entries and returns the next ones plus a result summary, mutating
101
- * nothing. Throws `BridgeHubProtocolError` for an invalid request or an
102
- * unresolved project_id conflict; on throw, `registry` is guaranteed untouched
103
- * because nothing is written until every conflict check has passed.
101
+ * nothing. Throws `BridgeHubProtocolError` only for a malformed registry or
102
+ * request; a project_id already held by another terminal is no longer fatal —
103
+ * see the contention rules inline.
104
104
  */
105
- export function applyBridgeHubRegistration({ registry, request, remoteAddress, now = new Date() }) {
105
+ export function applyBridgeHubRegistration({ registry, request, remoteAddress, now = new Date(),
106
+ ttlMs = BRIDGE_HUB_HEARTBEAT_TTL_MS }) {
106
107
  if (!validRegistry(registry)) {
107
108
  throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRY_INVALID', 'bridge hub registry is invalid');
108
109
  }
@@ -119,30 +120,44 @@ export function applyBridgeHubRegistration({ registry, request, remoteAddress, n
119
120
  const requestedSet = new Set(projectIds);
120
121
  const adoptSet = new Set(adopt);
121
122
 
122
- const conflicts = registry
123
- .filter((entry) => requestedSet.has(entry.project_id)
124
- && entry.terminal_id !== terminalId && !adoptSet.has(entry.project_id))
125
- .map((entry) => ({ project_id: entry.project_id, owning_terminal_id: entry.terminal_id }));
126
- if (conflicts.length > 0) {
127
- throw new BridgeHubProtocolError('BRIDGE_HUB_PROJECT_CONFLICT',
128
- 'one or more project_ids are already owned by a different terminal',
129
- {
130
- conflicts: conflicts.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en')),
131
- next_action: 're-register naming the conflicting project_ids in adopt',
132
- });
133
- }
123
+ // A heartbeat carries the terminal's whole active set, so one contested
124
+ // project_id used to fail the entire request and strand every unrelated
125
+ // project on that terminal — invisibly, since the daemon only logs the
126
+ // rejection to a stderr nobody reads. Contested ids are now settled one at a
127
+ // time and the rest are accepted:
128
+ //
129
+ // owner offline (no heartbeat within the TTL) the entry is very likely a
130
+ // leftover from a terminal that moved on, so the live claimant takes it
131
+ // over. Nothing else ever retracts it: entries are never expired.
132
+ // owner online the project genuinely belongs to another live terminal.
133
+ // Reject just that id and report it, so the operator can adopt on purpose.
134
+ // Handing it over unconditionally would make two live terminals trade
135
+ // ownership every heartbeat and flip the published route back and forth.
136
+ const claimed = registry.filter((entry) => requestedSet.has(entry.project_id)
137
+ && entry.terminal_id !== terminalId && !adoptSet.has(entry.project_id));
138
+ const offline = (entry) => now.getTime() - Date.parse(entry.last_seen_at) > ttlMs;
139
+ const rejected = claimed.filter((entry) => !offline(entry))
140
+ .map((entry) => ({ project_id: entry.project_id, owning_terminal_id: entry.terminal_id }))
141
+ .sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
142
+ const reclaimed = sorted(claimed.filter(offline).map((entry) => entry.project_id));
143
+
144
+ const rejectedSet = new Set(rejected.map((entry) => entry.project_id));
145
+ const acceptedIds = projectIds.filter((projectId) => !rejectedSet.has(projectId));
146
+ const acceptedSet = new Set(acceptedIds);
134
147
 
135
148
  const registeredAtByProject = new Map(registry
136
149
  .filter((entry) => entry.terminal_id === terminalId)
137
150
  .map((entry) => [entry.project_id, entry.registered_at]));
138
- const adopted = sorted(projectIds.filter((projectId) => adoptSet.has(projectId)
151
+ const adopted = sorted(acceptedIds.filter((projectId) => adoptSet.has(projectId)
139
152
  && !registeredAtByProject.has(projectId)));
140
153
  // Full-state reconciliation: every entry this terminal owned is dropped, then
141
- // rebuilt from `projectIds`. A project omitted from this call — dropped
154
+ // rebuilt from the accepted ids. A project omitted from this call — dropped
142
155
  // locally, or never re-sent after a crash — is released, not left as a
143
- // phantom no future heartbeat can retract.
144
- const others = registry.filter((entry) => entry.terminal_id !== terminalId && !requestedSet.has(entry.project_id));
145
- const mine = projectIds.map((projectId) => ({
156
+ // phantom no future heartbeat can retract. Rejected ids stay with their live
157
+ // owner, so `others` keeps them.
158
+ const others = registry.filter((entry) => entry.terminal_id !== terminalId
159
+ && !acceptedSet.has(entry.project_id));
160
+ const mine = acceptedIds.map((projectId) => ({
146
161
  project_id: projectId,
147
162
  terminal_id: terminalId,
148
163
  display_name: displayName,
@@ -157,12 +172,18 @@ export function applyBridgeHubRegistration({ registry, request, remoteAddress, n
157
172
  return {
158
173
  registry: nextRegistry,
159
174
  result: {
160
- schema: 'lattice.bridge_hub_registration_result.v1',
175
+ // v2: partial acceptance. v1 answered a contested id with a 409 that threw
176
+ // the whole request away; v2 accepts what it can and names what it could
177
+ // not. A v1 terminal reading a v2 result sees a 200 and simply does not
178
+ // notice `rejected` — still strictly better than losing every project.
179
+ schema: 'lattice.bridge_hub_registration_result.v2',
161
180
  terminal_id: terminalId,
162
181
  address: remoteAddress,
163
182
  port,
164
- registered: sorted(projectIds),
183
+ registered: sorted(acceptedIds),
165
184
  adopted,
185
+ rejected,
186
+ reclaimed_from_offline: reclaimed,
166
187
  },
167
188
  };
168
189
  }
@@ -170,8 +191,9 @@ export function applyBridgeHubRegistration({ registry, request, remoteAddress, n
170
191
  /**
171
192
  * Read-time projection for the aggregate `/projects/` view and per-project
172
193
  * routing. Never mutates or drops entries — a terminal that stopped
173
- * heartbeating shows as 'offline' forever, not gone, until it either
174
- * re-registers or a different terminal explicitly adopts the project.
194
+ * heartbeating shows as 'offline' forever, not gone, until it re-registers, a
195
+ * different terminal explicitly adopts the project, or a different terminal
196
+ * claims it while this one is past the TTL (see applyBridgeHubRegistration).
175
197
  */
176
198
  export function projectBridgeHubRegistry({ registry, now = new Date(), ttlMs = BRIDGE_HUB_HEARTBEAT_TTL_MS }) {
177
199
  if (!validRegistry(registry)) {
@@ -35,11 +35,13 @@ import {
35
35
  applyBridgeHubRegistration, BRIDGE_HUB_HEARTBEAT_TTL_MS, BridgeHubProtocolError,
36
36
  projectBridgeHubRegistry, validateBridgeHubRegistryEntry,
37
37
  } from './bridge-hub-protocol.mjs';
38
+ import { escapeHtml, renderBridgeHubLanding } from './bridge-hub-landing.mjs';
38
39
 
39
40
  const LOOPBACK = '127.0.0.1';
40
41
  const HUB_HTTP_ERROR_SCHEMA = 'lattice.bridge_hub_http_error.v1';
41
42
  const HUB_REGISTRY_DOCUMENT_SCHEMA = 'lattice.bridge_hub_registry_document.v1';
42
43
  const HUB_PUBLIC_PROJECT_SCHEMA = 'lattice.bridge_hub_public_project.v1';
44
+ const HUB_PUBLIC_VISIBILITY_SCHEMA = 'lattice.bridge_hub_public_visibility.v1';
43
45
  const MAX_REGISTRATION_BODY_BYTES = 65_536;
44
46
  const LOCK_ATTEMPTS = 240;
45
47
  const LOCK_WAIT_MS = 25;
@@ -65,11 +67,6 @@ export class BridgeHubServerError extends Error {
65
67
  }
66
68
  }
67
69
 
68
- function escapeHtml(value) {
69
- return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;')
70
- .replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
71
- }
72
-
73
70
  // --- Host allow-list validation (equivalent to bridge-server.mjs's
74
71
  // validatedRequestHost/normalizeBridgeAllowedHost, reimplemented locally so
75
72
  // this module does not depend on a file a concurrent task is editing). ---
@@ -176,31 +173,9 @@ function respondError(response, status, code, detail = null) {
176
173
  response.end(`${JSON.stringify(body)}\n`);
177
174
  }
178
175
 
179
- // This must stay visually identical to todo-gantt-live.mjs's dashboardHtml
180
- // (same shell/brand/card markup, same design tokens) the public entrance
181
- // changing its own look when the routing behind it changed from single- to
182
- // multi-terminal is exactly the regression the owner flagged (room 2474;
183
- // plan_bridge-hub.md's non-goal "dashboard renderer/gantt UIの変更はしない"
184
- // covers keeping this landing's appearance, not just the diagrams behind it).
185
- // Reimplemented locally rather than imported: this codebase's bridge modules
186
- // each keep their own copy of such patterns rather than cross-importing
187
- // (see bh2-hub-server.md's rationale for validatedHubHost et al.), and the
188
- // only genuinely new thing here — an online/offline badge per project — has
189
- // no home in the single-terminal original to import from anyway.
190
- function hubIndexHtml(view) {
191
- const rows = view.map((project) => {
192
- const href = `/projects/${encodeURIComponent(project.project_id)}/`;
193
- const online = project.status === 'online';
194
- const statusLabel = online ? 'オンライン' : 'オフライン';
195
- const statusClass = online ? 'status-online' : 'status-offline';
196
- const identity = project.display_name === project.project_id ? '' : `<code>${escapeHtml(project.project_id)}</code>`;
197
- return `<li><a href="${escapeHtml(href)}"><strong>${escapeHtml(project.display_name)}</strong>`
198
- + `${identity}<span class="${statusClass}">${escapeHtml(statusLabel)}</span>`
199
- + `<span aria-hidden="true">→</span></a></li>`;
200
- }).join('');
201
- const content = rows.length === 0 ? '<p>登録されている端末はありません。</p>' : `<ul>${rows}</ul>`;
202
- return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="robots" content="noindex, nofollow"><meta property="og:title" content="公開中の工程表 — Lattice"><meta property="og:description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="theme-color" content="#f7f3ea"><title>公開中の工程表 — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a;--good:#0ca30c;--critical:#d03b3b}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,sans-serif}.shell{max-width:880px;margin:0 auto;padding:28px 22px 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a,.footer a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover,.footer a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:64px 0 72px}.eyebrow{margin:0 0 8px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}.lead{max-width:620px;margin:0 0 34px;color:var(--soft)}h1{margin:0 0 14px;font-size:clamp(2rem,6vw,3.4rem);line-height:1.12;letter-spacing:-.04em}ul{display:grid;gap:12px;margin:0;padding:0;list-style:none}li a{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 20px;border:1px solid var(--line);border-radius:12px;color:inherit;background:var(--panel);text-decoration:none;box-shadow:0 8px 28px rgba(48,39,27,.04)}li a:hover{border-color:var(--cobalt);transform:translateY(-1px)}li strong{font-size:1.04rem}li code{color:var(--soft);font-size:.78rem}li .status-online{color:var(--good);font-weight:800}li .status-offline{color:var(--critical);font-weight:800}li>a>span[aria-hidden]{color:var(--cobalt);font-weight:800}.note{margin:28px 0 0;padding:16px 18px;border-left:3px solid var(--orange);color:var(--soft);background:rgba(255,253,248,.72);font-size:.88rem}.footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;padding-top:20px;border-top:1px solid var(--line);color:var(--soft);font-size:.82rem}.footer nav{display:flex;gap:18px}@media(max-width:560px){.shell{padding:20px 16px 32px}main{padding:44px 0 56px}li a{grid-template-columns:minmax(0,1fr) auto;padding:16px}li code{grid-column:1/-1;grid-row:2}.footer{display:block}.footer nav{margin-top:10px}}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">LIVE DEVELOPMENT</p><h1>公開中の工程表</h1><p class="lead">Latticeが管理しているプロジェクトの工程と、いまどこまで進んでいるかを公開データから確認できます。</p>${content}<p class="note">表示内容はLatticeの記録から自動生成されます。製品の紹介や使い方はGitHubをご覧ください。</p></main><footer class="footer"><span>kitepon.dev の開発工程を、Latticeで可視化しています。</span><nav aria-label="関連リンク"><a href="https://kitepon.dev/">kitepon.dev</a><a href="https://github.com/kitepon-rgb/Lattice">GitHub</a></nav></footer></div></body></html>`;
203
- }
176
+ // The landing renderer lives in bridge-hub-landing.mjs (ADR 0164: the hub
177
+ // landing is the public product face; the room-2474 "stay identical to
178
+ // dashboardHtml" constraint is superseded by that ADR).
204
179
 
205
180
  function hubProjectStatusHtml(code, projectId, requestPath, message) {
206
181
  return `<!doctype html><html lang="ja"><head><meta charset="utf-8">`
@@ -275,7 +250,12 @@ function hubRuntimeDir(env) {
275
250
 
276
251
  function hubRuntimePaths(env) {
277
252
  const root = hubRuntimeDir(env);
278
- return { root, registry: path.join(root, 'terminals.json'), lock: path.join(root, 'registry.lock') };
253
+ return {
254
+ root,
255
+ registry: path.join(root, 'terminals.json'),
256
+ lock: path.join(root, 'registry.lock'),
257
+ visibility: path.join(root, 'public-visibility.json'),
258
+ };
279
259
  }
280
260
 
281
261
  async function readJsonDocument(ref, missing) {
@@ -330,6 +310,51 @@ function validRegistryDocument(value) {
330
310
  && value.entries.every(validateBridgeHubRegistryEntry);
331
311
  }
332
312
 
313
+ const EMPTY_VISIBILITY = Object.freeze({
314
+ hidden_project_ids: Object.freeze([]), hidden_terminal_ids: Object.freeze([]), display_names: Object.freeze({}),
315
+ });
316
+
317
+ function validVisibilityDocument(value) {
318
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
319
+ && value.schema === HUB_PUBLIC_VISIBILITY_SCHEMA
320
+ && Object.keys(value).every((key) =>
321
+ ['schema', 'hidden_project_ids', 'hidden_terminal_ids', 'display_names'].includes(key))
322
+ && Array.isArray(value.hidden_project_ids)
323
+ && value.hidden_project_ids.every((entry) => typeof entry === 'string')
324
+ && Array.isArray(value.hidden_terminal_ids)
325
+ && value.hidden_terminal_ids.every((entry) => typeof entry === 'string')
326
+ && value.display_names !== null && typeof value.display_names === 'object'
327
+ && !Array.isArray(value.display_names)
328
+ && Object.values(value.display_names).every((entry) => typeof entry === 'string');
329
+ }
330
+
331
+ /**
332
+ * Read the operator's public-visibility file (ADR 0164 Decision 3): which
333
+ * projects/terminals stay off the public listing, and how project ids render.
334
+ * Hot-read per request so edits apply without a hub restart. A missing file
335
+ * hides nothing; a malformed file is a typed error, never a silent republish
336
+ * of an internal terminal.
337
+ */
338
+ export async function readBridgeHubPublicVisibility({ env = process.env } = {}) {
339
+ const refs = hubRuntimePaths(env);
340
+ let document;
341
+ try {
342
+ document = await readJsonDocument(refs.visibility, EMPTY_VISIBILITY);
343
+ } catch (error) {
344
+ throw new BridgeHubServerError('BRIDGE_HUB_VISIBILITY_FILE_INVALID',
345
+ `hub public-visibility JSON is invalid: ${refs.visibility}`, undefined, error);
346
+ }
347
+ if (document === EMPTY_VISIBILITY) return document;
348
+ if (!validVisibilityDocument(document)) {
349
+ throw new BridgeHubServerError('BRIDGE_HUB_VISIBILITY_FILE_INVALID', 'hub public-visibility file is invalid');
350
+ }
351
+ return {
352
+ hidden_project_ids: document.hidden_project_ids,
353
+ hidden_terminal_ids: document.hidden_terminal_ids,
354
+ display_names: document.display_names,
355
+ };
356
+ }
357
+
333
358
  /** Read the persisted registry. A missing file is an empty registry, not an error. */
334
359
  export async function readBridgeHubRegistry({ env = process.env } = {}) {
335
360
  const refs = hubRuntimePaths(env);
@@ -411,14 +436,17 @@ export async function startBridgeHubServer({
411
436
  try {
412
437
  result = await registrationLock(async () => {
413
438
  const entries = await store.read();
414
- const applied = applyBridgeHubRegistration({ registry: entries, request, remoteAddress, now: now() });
439
+ const applied = applyBridgeHubRegistration({
440
+ registry: entries, request, remoteAddress, now: now(), ttlMs,
441
+ });
415
442
  await store.write(applied.registry);
416
443
  return applied.result;
417
444
  });
418
445
  } catch (error) {
419
446
  if (error instanceof BridgeHubProtocolError) {
420
- const status = error.code === 'BRIDGE_HUB_PROJECT_CONFLICT' ? 409
421
- : error.code === 'BRIDGE_HUB_REGISTRATION_INVALID' ? 400 : 500;
447
+ // A contested project_id no longer throws — it comes back in the 200
448
+ // body's `rejected`. Only a malformed request or registry lands here.
449
+ const status = error.code === 'BRIDGE_HUB_REGISTRATION_INVALID' ? 400 : 500;
422
450
  respondError(response, status, error.code, error.detail ?? null);
423
451
  return;
424
452
  }
@@ -429,14 +457,22 @@ export async function startBridgeHubServer({
429
457
  response.end(`${JSON.stringify(result)}\n`);
430
458
  }
431
459
 
432
- async function handleProjectsIndex(incoming, response) {
460
+ async function handleLanding(incoming, response, lang) {
433
461
  if (incoming.method !== 'GET') {
434
462
  response.setHeader('allow', 'GET');
435
463
  respondError(response, 405, 'BRIDGE_HUB_METHOD_NOT_ALLOWED');
436
464
  return;
437
465
  }
438
466
  const entries = await store.read();
439
- const projected = projectBridgeHubRegistry({ registry: entries, now: now(), ttlMs });
467
+ const visibility = await readBridgeHubPublicVisibility({ env });
468
+ const hiddenProjects = new Set(visibility.hidden_project_ids);
469
+ const hiddenTerminals = new Set(visibility.hidden_terminal_ids);
470
+ // The visibility filter applies to the listing only (both HTML and JSON);
471
+ // `/projects/<hidden>/` keeps proxying — hidden is not private (ADR 0164
472
+ // Decision 3, deliberately: smoke tests fetch their own page, operators
473
+ // keep the direct URL).
474
+ const projected = projectBridgeHubRegistry({ registry: entries, now: now(), ttlMs })
475
+ .filter((entry) => !hiddenProjects.has(entry.project_id) && !hiddenTerminals.has(entry.terminal_id));
440
476
  // Only the fields humans need for the public index are exposed; internal
441
477
  // routing fields (terminal_id, address, port) stay server-side, matching
442
478
  // the codebase's existing posture of not leaking topology to public
@@ -455,7 +491,7 @@ export async function startBridgeHubServer({
455
491
  response.end(`${JSON.stringify(view)}\n`);
456
492
  return;
457
493
  }
458
- const html = hubIndexHtml(view);
494
+ const html = renderBridgeHubLanding({ projects: view, lang, displayNames: visibility.display_names });
459
495
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
460
496
  'x-content-type-options': 'nosniff' });
461
497
  response.end(html);
@@ -523,17 +559,26 @@ export async function startBridgeHubServer({
523
559
  return;
524
560
  }
525
561
  const rawPath = requestUrl.split('?', 1)[0];
526
- if (rawPath === '/') {
527
- // The old single-terminal bridge served the project index at root; hub only ever
528
- // routed `/projects/*`, so the public entrance 404'd (room 2488 a functional
529
- // regression, not a design one: the front door itself was gone, independent of how
530
- // it looks). Redirect rather than duplicate handleProjectsIndex's logic at a second path.
531
- response.writeHead(301, { location: '/projects/', 'cache-control': 'no-store' });
562
+ if (rawPath === '/__lattice/hub/register') { await handleRegister(incoming, response); return; }
563
+ // The landing (ADR 0164) serves the same page at `/` and `/projects/`
564
+ // (the old `/` -> `/projects/` 301 is retired: both public inbound links
565
+ // land on the product face), with the EN variant path-separated under
566
+ // `/en/` mirroring the root site's convention. These literal matches must
567
+ // stay ahead of PROJECT_ROUTE so `/projects/` never parses as a project id.
568
+ if (rawPath === '/' || rawPath === '/projects/') { await handleLanding(incoming, response, 'ja'); return; }
569
+ if (rawPath === '/en/' || rawPath === '/en/projects/') { await handleLanding(incoming, response, 'en'); return; }
570
+ if (rawPath === '/en') {
571
+ response.writeHead(301, { location: '/en/', 'cache-control': 'no-store' });
572
+ response.end();
573
+ return;
574
+ }
575
+ if (rawPath.startsWith('/en/projects/')) {
576
+ // Per-project pages are language-neutral; strip the prefix instead of
577
+ // 404ing a hand-edited EN URL. Query strings survive the strip.
578
+ response.writeHead(301, { location: requestUrl.slice('/en'.length), 'cache-control': 'no-store' });
532
579
  response.end();
533
580
  return;
534
581
  }
535
- if (rawPath === '/__lattice/hub/register') { await handleRegister(incoming, response); return; }
536
- if (rawPath === '/projects/') { await handleProjectsIndex(incoming, response); return; }
537
582
  const match = PROJECT_ROUTE.exec(rawPath);
538
583
  if (match !== null) { await handleProjectProxy(incoming, response, requestUrl, match[1], host); return; }
539
584
  respondError(response, 404, 'BRIDGE_HUB_ROUTE_NOT_FOUND');
@@ -193,6 +193,7 @@ export async function startBridgeServer({
193
193
  instanceToken = null,
194
194
  resolveUpstream = (upstream) => resolveBridgeUpstream(upstream, { env }),
195
195
  interfaces = networkInterfaces(),
196
+ heartbeat = () => null,
196
197
  } = {}) {
197
198
  if (config?.enabled !== true) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
198
199
  if (!Array.isArray(config.allowed_hosts) || config.allowed_hosts.length === 0) {
@@ -240,7 +241,11 @@ export async function startBridgeServer({
240
241
  address: currentConfig.listen.address, port: currentConfig.listen.port,
241
242
  updated_at: currentConfig.updated_at ?? null,
242
243
  version: packageJson.version, node_path: process.execPath,
243
- node_version: process.version, bridge_path: process.argv[1] ?? null }
244
+ node_version: process.version, bridge_path: process.argv[1] ?? null,
245
+ // The hub's answer to our last registration. Nothing else carries it
246
+ // out of the daemon: the LaunchAgent has no StandardErrorPath, so a
247
+ // hub refusing our projects was previously invisible everywhere.
248
+ last_heartbeat: heartbeat() }
244
249
  : { schema: 'lattice.bridge_health.v1', status: 'available' })}\n`);
245
250
  return;
246
251
  }
@@ -321,6 +326,7 @@ export function bridgeRuntimeController({
321
326
  register = registerBridgeUpstream,
322
327
  report = (line) => process.stderr.write(`${line}\n`),
323
328
  readInterfaces = () => networkInterfaces(),
329
+ heartbeat = () => null,
324
330
  } = {}) {
325
331
  let active = null;
326
332
  let fingerprint = null;
@@ -371,7 +377,7 @@ export function bridgeRuntimeController({
371
377
  fingerprint = next;
372
378
  return active;
373
379
  }
374
- const replacement = await startBridgeServer({ config, env, instanceToken, interfaces });
380
+ const replacement = await startBridgeServer({ config, env, instanceToken, interfaces, heartbeat });
375
381
  const previous = active;
376
382
  active = replacement;
377
383
  fingerprint = next;
package/src/cli-help.mjs CHANGED
@@ -43,6 +43,7 @@ Commands:
43
43
  intake --run .lattice/runs/<id> --task <task_id>
44
44
  intake release --run .lattice/runs/<id> --task <task_id>
45
45
  intake attach --run .lattice/runs/<id> --task <task_id> --input <worker.json>
46
+ intake detach --run .lattice/runs/<id> --task <task_id>
46
47
  intake intervention --run .lattice/runs/<id> --task <task_id>
47
48
  intake accept --run .lattice/runs/<id> --task <task_id>
48
49
  adapter register --input <descriptor.json>
@@ -194,6 +195,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
194
195
  'run intake': 'run intake --run .lattice/runs/<id> --task <task_id>',
195
196
  'run intake release': 'run intake release --run .lattice/runs/<id> --task <task_id>',
196
197
  'run intake attach': 'run intake attach --run .lattice/runs/<id> --task <task_id> --input <worker.json>',
198
+ 'run intake detach': 'run intake detach --run .lattice/runs/<id> --task <task_id>',
197
199
  'run intake intervention': 'run intake intervention --run .lattice/runs/<id> --task <task_id>',
198
200
  'run intake accept': 'run intake accept --run .lattice/runs/<id> --task <task_id>',
199
201
  'run adapter register': 'run adapter register --input <descriptor.json> | --schema --json',