@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.
- package/bin/lattice-bridge.mjs +3 -2
- package/package.json +1 -1
- package/sensor/dist/index.d.ts.map +1 -1
- package/sensor/dist/index.js +11 -1
- package/sensor/dist/index.js.map +1 -1
- package/src/bridge-cli.mjs +14 -2
- package/src/bridge-daemon.mjs +5 -2
- package/src/bridge-hub-heartbeat.mjs +53 -7
- package/src/bridge-hub-landing.mjs +223 -0
- package/src/bridge-hub-protocol.mjs +47 -25
- package/src/bridge-hub-server.mjs +90 -45
- package/src/bridge-server.mjs +8 -2
- package/src/cli-help.mjs +2 -0
- package/src/runtime-cli.mjs +10 -0
- package/src/runtime-pull-intake.mjs +96 -3
- package/src/todo-cli.mjs +54 -16
- package/src/todo-gantt-live.mjs +3 -3
|
@@ -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('&', '&').replaceAll('<', '<')
|
|
87
|
+
.replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
145
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
|
174
|
-
*
|
|
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('&', '&').replaceAll('<', '<')
|
|
70
|
-
.replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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 {
|
|
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({
|
|
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
|
-
|
|
421
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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');
|
package/src/bridge-server.mjs
CHANGED
|
@@ -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',
|