@quolu/lattice 0.52.3 → 0.53.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/LICENSE +147 -147
- package/README.ja.md +355 -355
- package/README.md +258 -258
- package/bin/lattice-bridge.mjs +25 -0
- package/bin/lattice-hub.mjs +67 -0
- package/bin/lattice-mcp.mjs +0 -0
- package/bin/lattice-scripted-adapter.mjs +0 -0
- package/bin/lattice-scripted-worker.mjs +0 -0
- package/bin/lattice-work-order-adapter.mjs +0 -0
- package/bin/lattice.mjs +0 -0
- package/docs/bridge-setup.md +132 -132
- package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
- package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
- package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
- package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
- package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
- package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
- package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
- package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
- package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
- package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
- package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
- package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
- package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
- package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
- package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
- package/package.json +103 -103
- package/sensor/LICENSE +21 -21
- package/sensor/NOTICE +19 -19
- package/sensor/dist/bin/lattice-sensor.js +9 -9
- package/sensor/dist/db/index.js +24 -24
- package/sensor/dist/db/migrations.js +41 -41
- package/sensor/dist/db/queries.js +164 -164
- package/sensor/dist/db/schema.sql +205 -205
- package/sensor/dist/directory.js +5 -5
- package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
- package/sensor/dist/mcp/liveness-watchdog.js +53 -53
- package/sensor/dist/mcp/server-instructions.js +95 -95
- package/sensor/package.json +56 -56
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +303 -297
- package/src/bridge-config.mjs +382 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-hub-heartbeat.mjs +170 -0
- package/src/bridge-hub-protocol.mjs +198 -0
- package/src/bridge-hub-server.mjs +544 -0
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/git-process.mjs +72 -0
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1057
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +3 -3
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1506
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -524
- package/src/runtime-cli.mjs +4588 -4588
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -604
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -166
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -481
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -294
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -838
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1192
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1171
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +8 -8
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -629
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +42 -21
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-side bridge-hub heartbeat client (bh3).
|
|
3
|
+
*
|
|
4
|
+
* Wires the pure wire contract in `bridge-hub-protocol.mjs` (bh1) around this
|
|
5
|
+
* terminal's own state: a persisted terminal identity, the locally active
|
|
6
|
+
* project set (`todo-dashboard-registry.mjs`), and the configured hub origin
|
|
7
|
+
* (`bridge-config.mjs`'s `hub` field). It sends `POST /__lattice/hub/register`
|
|
8
|
+
* on `bridge-hub-server.mjs`'s (bh2) contract — the request body is the raw
|
|
9
|
+
* `lattice.bridge_hub_registration_request.v1` object, unmodified in transit.
|
|
10
|
+
*
|
|
11
|
+
* DHCP addresses are never read or sent here: ADR 0162 has the hub derive
|
|
12
|
+
* `address` from the registration connection's own source, the same safety
|
|
13
|
+
* property `bridge-registrar.mjs` relies on. A moved lease is invisible to
|
|
14
|
+
* this module by design — the next heartbeat just arrives from a new source.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { hostname } from 'node:os';
|
|
19
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { bridgeConfigPaths } from './bridge-config.mjs';
|
|
23
|
+
import { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS, validateBridgeHubRegistrationRequest } from './bridge-hub-protocol.mjs';
|
|
24
|
+
import { readActiveTodoDashboardProjects } from './todo-dashboard-registry.mjs';
|
|
25
|
+
|
|
26
|
+
export { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS };
|
|
27
|
+
|
|
28
|
+
const TERMINAL_IDENTITY_SCHEMA = 'lattice.bridge_hub_terminal_identity.v1';
|
|
29
|
+
const HEARTBEAT_RESULT_SCHEMA = 'lattice.bridge_hub_heartbeat_result.v1';
|
|
30
|
+
const REGISTRATION_REQUEST_SCHEMA = 'lattice.bridge_hub_registration_request.v1';
|
|
31
|
+
const REGISTER_PATH = '__lattice/hub/register';
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
33
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
34
|
+
|
|
35
|
+
export class BridgeHubHeartbeatError extends Error {
|
|
36
|
+
constructor(code, message, detail = undefined, cause = undefined) {
|
|
37
|
+
super(message, { cause });
|
|
38
|
+
this.name = 'BridgeHubHeartbeatError';
|
|
39
|
+
this.code = code;
|
|
40
|
+
if (detail !== undefined) this.detail = detail;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function terminalIdentityPath(env) {
|
|
45
|
+
return path.join(bridgeConfigPaths(env).root, 'bridge-hub-terminal.json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* This terminal's stable bridge-hub identity, created once and reused across
|
|
50
|
+
* restarts. It must survive restarts: registration is a full-state
|
|
51
|
+
* reconciliation keyed by terminal_id (ADR 0162 Decision 4), so a fresh id on
|
|
52
|
+
* every daemon start would make the hub see "a new terminal" contesting the
|
|
53
|
+
* same project_ids the old id still owns until its TTL lapses — a
|
|
54
|
+
* self-inflicted `BRIDGE_HUB_PROJECT_CONFLICT`.
|
|
55
|
+
*/
|
|
56
|
+
export async function readOrCreateBridgeHubTerminalId({ env = process.env } = {}) {
|
|
57
|
+
const refs = bridgeConfigPaths(env);
|
|
58
|
+
await mkdir(refs.root, { recursive: true, mode: 0o700 });
|
|
59
|
+
const ref = terminalIdentityPath(env);
|
|
60
|
+
for (;;) {
|
|
61
|
+
try {
|
|
62
|
+
const value = JSON.parse(await readFile(ref, 'utf8'));
|
|
63
|
+
if (value?.schema === TERMINAL_IDENTITY_SCHEMA && IDENTIFIER.test(value.terminal_id)) return value.terminal_id;
|
|
64
|
+
throw new BridgeHubHeartbeatError('BRIDGE_HUB_TERMINAL_IDENTITY_INVALID',
|
|
65
|
+
'bridge hub terminal identity file is invalid');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
68
|
+
}
|
|
69
|
+
const terminalId = randomBytes(16).toString('hex');
|
|
70
|
+
try {
|
|
71
|
+
await writeFile(ref, `${JSON.stringify({ schema: TERMINAL_IDENTITY_SCHEMA, terminal_id: terminalId })}\n`,
|
|
72
|
+
{ encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
73
|
+
return terminalId;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
76
|
+
// Lost the create race to another process; loop back and read what it wrote.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The terminal's own display name, shown for every project it registers
|
|
83
|
+
* (registry entries are per-project but `display_name` is terminal-wide —
|
|
84
|
+
* ADR 0162 Decision 2). Hostname, not a project name: the active project set
|
|
85
|
+
* changes heartbeat to heartbeat but the terminal's identity does not.
|
|
86
|
+
*/
|
|
87
|
+
function terminalDisplayName() {
|
|
88
|
+
return hostname().slice(0, 128) || 'terminal';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Build and validate one registration/heartbeat request. Throws rather than
|
|
92
|
+
* sending a request the hub would reject as malformed. */
|
|
93
|
+
export function buildBridgeHubRegistrationRequest({ terminalId, port, projectIds, adopt = [] }) {
|
|
94
|
+
const request = {
|
|
95
|
+
schema: REGISTRATION_REQUEST_SCHEMA,
|
|
96
|
+
terminal_id: terminalId,
|
|
97
|
+
display_name: terminalDisplayName(),
|
|
98
|
+
port,
|
|
99
|
+
project_ids: [...new Set(projectIds)].sort((left, right) => left.localeCompare(right, 'en')),
|
|
100
|
+
adopt: [...adopt],
|
|
101
|
+
};
|
|
102
|
+
if (!validateBridgeHubRegistrationRequest(request)) {
|
|
103
|
+
throw new BridgeHubHeartbeatError('BRIDGE_HUB_HEARTBEAT_REQUEST_INVALID',
|
|
104
|
+
'constructed bridge hub registration request is invalid', { request });
|
|
105
|
+
}
|
|
106
|
+
return request;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Send one heartbeat. Never throws for a remote or network failure — the
|
|
111
|
+
* bridge daemon loop calling this must keep serving locally even when the
|
|
112
|
+
* hub is unreachable, matching `bridge-registrar.mjs`'s posture. Failures are
|
|
113
|
+
* returned typed so callers can surface or log them instead of losing them.
|
|
114
|
+
*/
|
|
115
|
+
export async function sendBridgeHubHeartbeat({ hubUrl, request, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
116
|
+
let response;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetchImpl(new URL(REGISTER_PATH, hubUrl), {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'content-type': 'application/json' },
|
|
121
|
+
body: JSON.stringify(request),
|
|
122
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'unreachable',
|
|
126
|
+
detail: (error?.message ?? 'network error').slice(0, 500) };
|
|
127
|
+
}
|
|
128
|
+
let body = null;
|
|
129
|
+
try { body = await response.json(); } catch { body = null; }
|
|
130
|
+
if (response.status !== 200) {
|
|
131
|
+
return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'rejected', status: response.status, detail: body };
|
|
132
|
+
}
|
|
133
|
+
return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'accepted', result: body };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Periodic controller for the bridge daemon's own poll loop
|
|
138
|
+
* (`bin/lattice-bridge.mjs`). Call `tick({ config })` on every iteration; it
|
|
139
|
+
* self-throttles to `intervalMs` and is a no-op (no disk or network access)
|
|
140
|
+
* whenever the terminal has no hub configured, so callers pay only a null
|
|
141
|
+
* check on the common path.
|
|
142
|
+
*/
|
|
143
|
+
export function createBridgeHubHeartbeatController({
|
|
144
|
+
env = process.env, fetchImpl = fetch, now = () => Date.now(),
|
|
145
|
+
intervalMs = BRIDGE_HUB_HEARTBEAT_INTERVAL_MS,
|
|
146
|
+
readActiveProjects = readActiveTodoDashboardProjects,
|
|
147
|
+
} = {}) {
|
|
148
|
+
let lastSentAt = null;
|
|
149
|
+
let lastResult = null;
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
async tick({ config }) {
|
|
152
|
+
if (config?.hub == null) { lastSentAt = null; lastResult = null; return null; }
|
|
153
|
+
const nowMs = now();
|
|
154
|
+
if (lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
|
|
155
|
+
lastSentAt = nowMs;
|
|
156
|
+
const projects = await readActiveProjects({ env });
|
|
157
|
+
if (projects.length === 0) {
|
|
158
|
+
lastResult = { schema: HEARTBEAT_RESULT_SCHEMA, state: 'skipped_no_projects' };
|
|
159
|
+
return lastResult;
|
|
160
|
+
}
|
|
161
|
+
const terminalId = await readOrCreateBridgeHubTerminalId({ env });
|
|
162
|
+
const request = buildBridgeHubRegistrationRequest({
|
|
163
|
+
terminalId, port: config.listen.port, projectIds: projects.map((project) => project.project_id),
|
|
164
|
+
});
|
|
165
|
+
lastResult = await sendBridgeHubHeartbeat({ hubUrl: config.hub.url, request, fetchImpl });
|
|
166
|
+
return lastResult;
|
|
167
|
+
},
|
|
168
|
+
lastHeartbeatResult: () => lastResult,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge hub registration protocol — the wire contract between a terminal's
|
|
3
|
+
* `lattice bridge` and the hub that aggregates them for the public dashboard.
|
|
4
|
+
*
|
|
5
|
+
* This module owns validation and pure registry mutation only. It does not open
|
|
6
|
+
* a socket, read a clock, or touch disk — that belongs to the hub server (bh2)
|
|
7
|
+
* and the terminal heartbeat client (bh3). Keeping the contract pure lets it be
|
|
8
|
+
* characterization-tested before either side exists, and keeps bh2/bh3 from
|
|
9
|
+
* inventing their own conflict or staleness rules.
|
|
10
|
+
*
|
|
11
|
+
* Design decisions this module encodes (see docs/adr/0162 for the full record):
|
|
12
|
+
*
|
|
13
|
+
* - The registry is keyed by `project_id`, not `terminal_id`, because hub routes
|
|
14
|
+
* `/projects/<id>/*` by project. A terminal that owns several projects appears
|
|
15
|
+
* as several entries sharing the same terminal_id/address/port.
|
|
16
|
+
* - The request never carries an address. The caller passes the address the
|
|
17
|
+
* registration connection actually arrived from (`remoteAddress`); a terminal
|
|
18
|
+
* can only ever register itself, the same safety property bridge-registrar.mjs
|
|
19
|
+
* already relies on for the ssh-based upstream registrar.
|
|
20
|
+
* - A registration call declares a terminal's *complete* current project
|
|
21
|
+
* portfolio and replaces that terminal's prior contribution wholesale. A
|
|
22
|
+
* project the terminal owned before but omits now is released — there is no
|
|
23
|
+
* separate "deregister" verb, and no partial application: a heartbeat either
|
|
24
|
+
* lands in full or is rejected in full.
|
|
25
|
+
* - A project_id already owned by a *different* terminal is a conflict unless
|
|
26
|
+
* named in `adopt`. Conflicts are collected and reported together — a batch
|
|
27
|
+
* never partially lands, so a caller is never left unsure which half won.
|
|
28
|
+
* - Staleness is read-time only. `projectBridgeHubRegistry` marks an entry
|
|
29
|
+
* 'offline' once `last_seen_at` exceeds the TTL; it never deletes the entry.
|
|
30
|
+
* The plan's fail-closed requirement is "show offline", not "make it vanish".
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
34
|
+
const MAX_PROJECT_IDS = 256;
|
|
35
|
+
|
|
36
|
+
/** Suggested terminal heartbeat cadence. Not part of the wire contract — the hub
|
|
37
|
+
* owns both constants and can retune them without a protocol version bump. */
|
|
38
|
+
export const BRIDGE_HUB_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
39
|
+
/** Grace window after the last heartbeat before a project is shown offline. */
|
|
40
|
+
export const BRIDGE_HUB_HEARTBEAT_TTL_MS = 90_000;
|
|
41
|
+
|
|
42
|
+
export class BridgeHubProtocolError extends Error {
|
|
43
|
+
constructor(code, message, detail = null) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'BridgeHubProtocolError';
|
|
46
|
+
this.code = code;
|
|
47
|
+
this.detail = detail;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function identifier(value) {
|
|
52
|
+
return typeof value === 'string' && IDENTIFIER.test(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function uniqueIdentifierArray(value, { min = 0, max = MAX_PROJECT_IDS } = {}) {
|
|
56
|
+
return Array.isArray(value) && value.length >= min && value.length <= max
|
|
57
|
+
&& value.every(identifier) && new Set(value).size === value.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sorted(ids) {
|
|
61
|
+
return [...ids].sort((left, right) => left.localeCompare(right, 'en'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A terminal's registration/heartbeat request, as it crosses the wire. */
|
|
65
|
+
export function validateBridgeHubRegistrationRequest(value) {
|
|
66
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
67
|
+
&& Object.keys(value).sort().join(',') === 'adopt,display_name,port,project_ids,schema,terminal_id'
|
|
68
|
+
&& value.schema === 'lattice.bridge_hub_registration_request.v1'
|
|
69
|
+
&& identifier(value.terminal_id)
|
|
70
|
+
&& typeof value.display_name === 'string' && value.display_name.length > 0
|
|
71
|
+
&& value.display_name === value.display_name.trim()
|
|
72
|
+
&& Number.isSafeInteger(value.port) && value.port > 0 && value.port <= 65_535
|
|
73
|
+
&& uniqueIdentifierArray(value.project_ids, { min: 1 })
|
|
74
|
+
&& uniqueIdentifierArray(value.adopt, { max: value.project_ids.length })
|
|
75
|
+
&& value.adopt.every((projectId) => value.project_ids.includes(projectId));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A single project's routing entry as stored in the hub registry. */
|
|
79
|
+
function validRegistryEntry(entry) {
|
|
80
|
+
return entry !== null && typeof entry === 'object' && !Array.isArray(entry)
|
|
81
|
+
&& Object.keys(entry).sort().join(',')
|
|
82
|
+
=== 'address,display_name,last_seen_at,port,project_id,registered_at,terminal_id'
|
|
83
|
+
&& identifier(entry.project_id) && identifier(entry.terminal_id)
|
|
84
|
+
&& typeof entry.display_name === 'string' && entry.display_name.length > 0
|
|
85
|
+
&& typeof entry.address === 'string' && entry.address.length > 0
|
|
86
|
+
&& Number.isSafeInteger(entry.port) && entry.port > 0 && entry.port <= 65_535
|
|
87
|
+
&& Number.isFinite(Date.parse(entry.registered_at)) && Number.isFinite(Date.parse(entry.last_seen_at));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function validateBridgeHubRegistryEntry(value) {
|
|
91
|
+
return validRegistryEntry(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validRegistry(registry) {
|
|
95
|
+
return Array.isArray(registry) && registry.every(validRegistryEntry);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply one registration/heartbeat call to a registry snapshot. Pure: takes the
|
|
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.
|
|
104
|
+
*/
|
|
105
|
+
export function applyBridgeHubRegistration({ registry, request, remoteAddress, now = new Date() }) {
|
|
106
|
+
if (!validRegistry(registry)) {
|
|
107
|
+
throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRY_INVALID', 'bridge hub registry is invalid');
|
|
108
|
+
}
|
|
109
|
+
if (!validateBridgeHubRegistrationRequest(request)) {
|
|
110
|
+
throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'bridge hub registration request is invalid');
|
|
111
|
+
}
|
|
112
|
+
if (typeof remoteAddress !== 'string' || remoteAddress.length === 0) {
|
|
113
|
+
throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'remote address is required');
|
|
114
|
+
}
|
|
115
|
+
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) {
|
|
116
|
+
throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'now must be a valid Date');
|
|
117
|
+
}
|
|
118
|
+
const { terminal_id: terminalId, display_name: displayName, port, project_ids: projectIds, adopt } = request;
|
|
119
|
+
const requestedSet = new Set(projectIds);
|
|
120
|
+
const adoptSet = new Set(adopt);
|
|
121
|
+
|
|
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
|
+
}
|
|
134
|
+
|
|
135
|
+
const registeredAtByProject = new Map(registry
|
|
136
|
+
.filter((entry) => entry.terminal_id === terminalId)
|
|
137
|
+
.map((entry) => [entry.project_id, entry.registered_at]));
|
|
138
|
+
const adopted = sorted(projectIds.filter((projectId) => adoptSet.has(projectId)
|
|
139
|
+
&& !registeredAtByProject.has(projectId)));
|
|
140
|
+
// Full-state reconciliation: every entry this terminal owned is dropped, then
|
|
141
|
+
// rebuilt from `projectIds`. A project omitted from this call — dropped
|
|
142
|
+
// 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) => ({
|
|
146
|
+
project_id: projectId,
|
|
147
|
+
terminal_id: terminalId,
|
|
148
|
+
display_name: displayName,
|
|
149
|
+
address: remoteAddress,
|
|
150
|
+
port,
|
|
151
|
+
registered_at: registeredAtByProject.get(projectId) ?? now.toISOString(),
|
|
152
|
+
last_seen_at: now.toISOString(),
|
|
153
|
+
}));
|
|
154
|
+
const nextRegistry = [...others, ...mine]
|
|
155
|
+
.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
registry: nextRegistry,
|
|
159
|
+
result: {
|
|
160
|
+
schema: 'lattice.bridge_hub_registration_result.v1',
|
|
161
|
+
terminal_id: terminalId,
|
|
162
|
+
address: remoteAddress,
|
|
163
|
+
port,
|
|
164
|
+
registered: sorted(projectIds),
|
|
165
|
+
adopted,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read-time projection for the aggregate `/projects/` view and per-project
|
|
172
|
+
* 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.
|
|
175
|
+
*/
|
|
176
|
+
export function projectBridgeHubRegistry({ registry, now = new Date(), ttlMs = BRIDGE_HUB_HEARTBEAT_TTL_MS }) {
|
|
177
|
+
if (!validRegistry(registry)) {
|
|
178
|
+
throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRY_INVALID', 'bridge hub registry is invalid');
|
|
179
|
+
}
|
|
180
|
+
if (!(now instanceof Date) || !Number.isFinite(now.getTime())) {
|
|
181
|
+
throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'now must be a valid Date');
|
|
182
|
+
}
|
|
183
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
184
|
+
throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'ttlMs must be a positive integer');
|
|
185
|
+
}
|
|
186
|
+
return registry
|
|
187
|
+
.map((entry) => ({
|
|
188
|
+
schema: 'lattice.bridge_hub_registry_projection_entry.v1',
|
|
189
|
+
project_id: entry.project_id,
|
|
190
|
+
terminal_id: entry.terminal_id,
|
|
191
|
+
display_name: entry.display_name,
|
|
192
|
+
address: entry.address,
|
|
193
|
+
port: entry.port,
|
|
194
|
+
status: now.getTime() - Date.parse(entry.last_seen_at) <= ttlMs ? 'online' : 'offline',
|
|
195
|
+
last_seen_at: entry.last_seen_at,
|
|
196
|
+
}))
|
|
197
|
+
.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
|
|
198
|
+
}
|