@quolu/lattice 0.12.11 → 0.12.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.12.11",
3
+ "version": "0.12.13",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,7 +54,7 @@
54
54
  "test": "node scripts/run-product-tests.mjs",
55
55
  "test:sensor": "npm --prefix sensor test",
56
56
  "precheck": "node --check src/bridge-launch-agent.mjs",
57
- "check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check src/cli-stdio.mjs && node --check src/todo-gantt-layout.mjs && node --check src/todo-gantt-scope.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
57
+ "check": "node --check bin/lattice.mjs && node --check bin/lattice-mcp.mjs && node --check bin/lattice-dashboard.mjs && node --check bin/lattice-bridge.mjs && node --check src/cli-stdio.mjs && node --check src/bridge-address.mjs && node --check src/bridge-registrar.mjs && node --check src/todo-gantt-layout.mjs && node --check src/todo-gantt-scope.mjs && node --check src/bridge-config.mjs && node --check src/bridge-server.mjs && node --check src/bridge-daemon.mjs && node --check src/bridge-cli.mjs && node --check src/project-cli.mjs && node --check src/sensor-cli.mjs && node --check src/sensor-runtime.mjs && node --check src/sensor-adapter.mjs && node --check src/factory-diagnostics.mjs && node --check src/runtime-errors.mjs && node --check src/runtime-contracts.mjs && node --check src/runtime-event-store.mjs && node --check src/hash-chain.mjs && node --check src/dag-chain.mjs && node --check src/todo-contracts.mjs && node --check src/todo-chain.mjs && node --check src/todo-store.mjs && node --check src/todo-migration.mjs && node --check src/todo-revision.mjs && node --check src/todo-status.mjs && node --check src/todo-cli.mjs && node --check src/todo-dashboard-registry.mjs && node --check src/todo-gantt-presentation.mjs && node --check src/todo-gantt-live.mjs && node --check src/bounded-seam.mjs && node --check src/todo-narrative-anchor.mjs && node --check src/todo-markdown-renderer.mjs && node --check src/todo-gantt-svg.mjs && node --check src/todo-gantt-html.mjs && node --check src/runtime-projection.mjs && node --check src/runtime-decision-verifier.mjs && node --check src/runtime-front-end.mjs && node --check src/runtime-cli.mjs && node --check src/rc3-dogfood-scaffold.mjs && node --check src/runtime-engine.mjs && node --check src/runtime-scripted-executor.mjs && node --check src/runtime-diff-observer.mjs && node --check src/runtime-worktree-executor.mjs && node --check src/runtime-hold-recompile.mjs && node --check src/rc3-scripted-campaign.mjs && node --check src/rc3-actual-dogfood.mjs && node --check src/rc4-stage1-dogfood.mjs && node --check research/fixtures/dispatch-record/src/dispatch-record.mjs && node --check test/research-dispatch-record.test.mjs",
58
58
  "check:project-identity": "node --check src/project-identity.mjs",
59
59
  "ci": "npm run test && npm run test:sensor && npm run check && npm run check:project-identity"
60
60
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Bridge listen-address resolution.
3
+ *
4
+ * The bridge is configured with a literal IP so the operator controls exactly
5
+ * which interface it is exposed on. A DHCP lease change silently invalidates
6
+ * that choice: the address disappears from the host, the old socket lingers on
7
+ * a dead address, and nothing on the LAN can reach the bridge any more —
8
+ * while `bridge status` keeps reporting `enabled: true`.
9
+ *
10
+ * This module answers two questions without any I/O:
11
+ * 1. is the configured address still present on this host?
12
+ * 2. if not, is there an address that is obviously the same intent?
13
+ *
14
+ * "Same intent" is deliberately narrow. A DHCP lease change moves the host
15
+ * inside its own subnet, so only a non-internal address of the same family in
16
+ * the same subnet (IPv4 /24, IPv6 /64) is adopted. An address on a different
17
+ * network — a VPN, a second NIC, a public interface — is never adopted
18
+ * automatically, because that would expose the bridge somewhere the operator
19
+ * did not choose. Loopback is never adopted either: it would silently take the
20
+ * bridge off the LAN while still looking healthy.
21
+ */
22
+
23
+ import { isIP } from 'node:net';
24
+
25
+ /** Wildcards already bind every interface, so they are never "absent". */
26
+ const WILDCARDS = new Set(['0.0.0.0', '::']);
27
+
28
+ export const BRIDGE_LISTEN_STATES = Object.freeze([
29
+ 'present', 'rebindable', 'absent', 'unconfigured',
30
+ ]);
31
+
32
+ function normalize(value) {
33
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
34
+ }
35
+
36
+ /**
37
+ * Flatten `os.networkInterfaces()` into the addresses a bridge may bind.
38
+ * Internal (loopback) addresses are kept but marked, so callers can exclude
39
+ * them from adoption while still recognising an explicitly configured one.
40
+ */
41
+ export function bridgeHostAddresses(interfaces = {}) {
42
+ const result = [];
43
+ for (const entries of Object.values(interfaces ?? {})) {
44
+ for (const entry of entries ?? []) {
45
+ const address = normalize(entry?.address);
46
+ if (address === '' || isIP(address) === 0) continue;
47
+ result.push({ address, family: isIP(address), internal: entry?.internal === true });
48
+ }
49
+ }
50
+ return result.sort((left, right) => (left.address < right.address ? -1
51
+ : left.address > right.address ? 1 : 0));
52
+ }
53
+
54
+ /** IPv4 /24 or IPv6 /64 prefix, used as the "same network" test. */
55
+ function subnetKey(address) {
56
+ const family = isIP(address);
57
+ if (family === 4) return address.split('.').slice(0, 3).join('.');
58
+ if (family !== 6) return null;
59
+ // Expand the IPv6 form enough to compare the first four hextets.
60
+ const [head] = address.split('%');
61
+ const parts = head.split('::');
62
+ const left = parts[0] === '' ? [] : parts[0].split(':');
63
+ const right = parts.length > 1 ? (parts[1] === '' ? [] : parts[1].split(':')) : [];
64
+ const fill = Array.from({ length: Math.max(0, 8 - left.length - right.length) }, () => '0');
65
+ const hextets = (parts.length > 1 ? [...left, ...fill, ...right] : left)
66
+ .map((entry) => entry.replace(/^0+(?=[0-9a-f])/u, ''));
67
+ return hextets.slice(0, 4).join(':');
68
+ }
69
+
70
+ /**
71
+ * Decide which address the bridge should actually listen on.
72
+ *
73
+ * @param {object} options
74
+ * @param {string|null} options.configured configured literal address.
75
+ * @param {object} [options.interfaces] `os.networkInterfaces()` shaped object.
76
+ * @returns {{state: string, effective: string|null, configured: string|null,
77
+ * candidates: string[], reason: string|null}}
78
+ */
79
+ export function resolveBridgeListenAddress({ configured, interfaces = {} } = {}) {
80
+ const wanted = normalize(configured);
81
+ if (wanted === '' || isIP(wanted) === 0) {
82
+ return { state: 'unconfigured', effective: null, configured: null, candidates: [], reason: 'listen_address_unconfigured' };
83
+ }
84
+ if (WILDCARDS.has(wanted)) {
85
+ return { state: 'present', effective: wanted, configured: wanted, candidates: [], reason: null };
86
+ }
87
+
88
+ const addresses = bridgeHostAddresses(interfaces);
89
+ if (addresses.some((entry) => entry.address === wanted)) {
90
+ return { state: 'present', effective: wanted, configured: wanted, candidates: [], reason: null };
91
+ }
92
+
93
+ const family = isIP(wanted);
94
+ const wantedSubnet = subnetKey(wanted);
95
+ const candidates = addresses
96
+ .filter((entry) => !entry.internal && entry.family === family
97
+ && wantedSubnet !== null && subnetKey(entry.address) === wantedSubnet)
98
+ .map((entry) => entry.address);
99
+
100
+ if (candidates.length === 0) {
101
+ return { state: 'absent', effective: null, configured: wanted, candidates: [],
102
+ reason: 'configured_address_absent_from_host' };
103
+ }
104
+ // Deterministic pick; `candidates` is reported so an ambiguous host is visible.
105
+ return { state: 'rebindable', effective: candidates[0], configured: wanted, candidates,
106
+ reason: 'configured_address_absent_rebound_within_subnet' };
107
+ }
@@ -1,6 +1,9 @@
1
- import { isIP } from 'node:net';
1
+ import { createConnection, isIP } from 'node:net';
2
+ import { networkInterfaces } from 'node:os';
2
3
  import * as clack from '@clack/prompts';
3
4
 
5
+ import { resolveBridgeListenAddress } from './bridge-address.mjs';
6
+ import { registerBridgeUpstream } from './bridge-registrar.mjs';
4
7
  import {
5
8
  BridgeConfigError, configureBridge, disableBridge, readBridgeConfig, restoreBridgeConfig,
6
9
  normalizeBridgeAllowedHost, withBridgeOperationLock,
@@ -15,7 +18,52 @@ import {
15
18
  snapshotBridgeLaunchAgent,
16
19
  } from './bridge-launch-agent.mjs';
17
20
 
18
- const RESULT_SCHEMA = 'lattice.bridge_cli_result.v1';
21
+ // v2 adds the liveness fields. `enabled` only says the configuration is on;
22
+ // it never said the bridge could actually be reached, which let a DHCP lease
23
+ // change take the published surface down while status kept reporting health.
24
+ const RESULT_SCHEMA = 'lattice.bridge_cli_result.v2';
25
+ const REACHABILITY_PROBE_TIMEOUT_MS = 750;
26
+
27
+ /** TCP connect probe. Answers "is anything accepting there right now". */
28
+ export function probeBridgeListener({ address, port, timeoutMs = REACHABILITY_PROBE_TIMEOUT_MS }) {
29
+ return new Promise((resolve) => {
30
+ let settled = false;
31
+ const finish = (value) => {
32
+ if (settled) return;
33
+ settled = true;
34
+ socket.destroy();
35
+ resolve(value);
36
+ };
37
+ const socket = createConnection({ host: address, port });
38
+ socket.setTimeout(timeoutMs);
39
+ socket.once('connect', () => finish(true));
40
+ socket.once('timeout', () => finish(false));
41
+ socket.once('error', () => finish(false));
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Liveness of the configured listen address: does it still exist on this host,
47
+ * did it move inside its subnet, and is anything accepting connections there.
48
+ */
49
+ async function bridgeLiveness(config, { interfaces = networkInterfaces(), probe = probeBridgeListener } = {}) {
50
+ if (config === null || config.enabled !== true) {
51
+ return { listen_state: 'unconfigured', effective_listen: null, listen_candidates: [],
52
+ reachable: null, liveness_reason: null };
53
+ }
54
+ const resolved = resolveBridgeListenAddress({ configured: config.listen.address, interfaces });
55
+ const effective = resolved.effective === null ? null
56
+ : { address: resolved.effective, port: config.listen.port };
57
+ const reachable = effective === null ? false
58
+ : await probe({ address: effective.address, port: effective.port });
59
+ return {
60
+ listen_state: resolved.state,
61
+ effective_listen: effective,
62
+ listen_candidates: resolved.candidates,
63
+ reachable,
64
+ liveness_reason: resolved.reason ?? (reachable ? null : 'listener_not_accepting'),
65
+ };
66
+ }
19
67
 
20
68
  function fail(stderr, code, message) {
21
69
  stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code, message })}\n`);
@@ -48,10 +96,15 @@ function parseOptions(words) {
48
96
  return options;
49
97
  }
50
98
 
51
- function result(action, config, recovery = null) {
99
+ function result(action, config, recovery = null, liveness = null) {
52
100
  return { schema: RESULT_SCHEMA, action, configured: config !== null, enabled: config?.enabled ?? false,
53
101
  listen: config?.listen ?? null, allowed_hosts: config?.allowed_hosts ?? null,
54
- upstream: config?.upstream ?? null, updated_at: config?.updated_at ?? null, recovery };
102
+ upstream: config?.upstream ?? null, updated_at: config?.updated_at ?? null, recovery,
103
+ listen_state: liveness?.listen_state ?? null,
104
+ effective_listen: liveness?.effective_listen ?? null,
105
+ listen_candidates: liveness?.listen_candidates ?? null,
106
+ reachable: liveness?.reachable ?? null,
107
+ liveness_reason: liveness?.liveness_reason ?? null };
55
108
  }
56
109
 
57
110
  export async function collectBridgeSetupWizard({ input, output, prompts = clack } = {}) {
@@ -104,13 +157,13 @@ export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
104
157
  stop: stopBridgeDaemon, clearStop: clearBridgeStopControl },
105
158
  launchAgent = { snapshot: snapshotBridgeLaunchAgent, install: installBridgeLaunchAgent,
106
159
  disable: disableBridgeLaunchAgent, restore: restoreBridgeLaunchAgent },
107
- prompts = clack } = {}) {
160
+ prompts = clack, probe = probeBridgeListener, interfaces = networkInterfaces() } = {}) {
108
161
  if (!Array.isArray(argv)) {
109
- return fail(stderr, 'USAGE', 'usage: lattice bridge <setup|reconfigure|status|disable> [options] --json');
162
+ return fail(stderr, 'USAGE', 'usage: lattice bridge <setup|reconfigure|status|disable|register> [options] --json');
110
163
  }
111
164
  const wizard = argv.length === 1 && argv[0] === 'setup';
112
165
  if (!wizard && argv.at(-1) !== '--json') {
113
- return fail(stderr, 'USAGE', 'usage: lattice bridge <setup|reconfigure|status|disable> [options] --json');
166
+ return fail(stderr, 'USAGE', 'usage: lattice bridge <setup|reconfigure|status|disable|register> [options] --json');
114
167
  }
115
168
  if (wizard && (!stdin?.isTTY || !stdout?.isTTY)) {
116
169
  return fail(stderr, 'BRIDGE_SETUP_REQUIRES_TTY',
@@ -118,6 +171,20 @@ export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
118
171
  }
119
172
  const [command, ...words] = wizard ? argv : argv.slice(0, -1);
120
173
  try {
174
+ if (command === 'register' && words.length === 0) {
175
+ const current = await readBridgeConfig({ env });
176
+ if (current === null || current.enabled !== true) {
177
+ return fail(stderr, 'BRIDGE_DISABLED', 'bridge is not enabled; nothing to register');
178
+ }
179
+ const resolved = resolveBridgeListenAddress({ configured: current.listen.address, interfaces });
180
+ if (resolved.effective === null) {
181
+ return fail(stderr, 'BRIDGE_LISTEN_ADDRESS_ABSENT',
182
+ 'configured bridge listen address is not present on this host');
183
+ }
184
+ const registration = await registerBridgeUpstream({ port: current.listen.port, env });
185
+ stdout.write(`${JSON.stringify(registration)}\n`);
186
+ return registration.state === 'failed' ? 1 : 0;
187
+ }
121
188
  let config;
122
189
  let recovery = null;
123
190
  if (command === 'status' && words.length === 0) config = await readBridgeConfig({ env });
@@ -216,8 +283,11 @@ export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
216
283
  return configured;
217
284
  });
218
285
  } else return fail(stderr, 'USAGE', 'unknown bridge command or options');
286
+ // Liveness is only meaningful for a read: the mutating commands have just
287
+ // reconfigured the daemon and the socket may not have settled yet.
288
+ const liveness = command === 'status' ? await bridgeLiveness(config, { probe, interfaces }) : null;
219
289
  if (wizard) stdout.write(`Lattice bridgeを${config.listen.address}:${config.listen.port}で有効にしました。\n`);
220
- else stdout.write(`${JSON.stringify(result(command, config, recovery))}\n`);
290
+ else stdout.write(`${JSON.stringify(result(command, config, recovery, liveness))}\n`);
221
291
  return 0;
222
292
  } catch (error) {
223
293
  stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2',
@@ -9,6 +9,7 @@ import { promisify } from 'node:util';
9
9
 
10
10
  import { BridgeConfigError, readBridgeConfig } from './bridge-config.mjs';
11
11
  import { readBridgeDaemonDescriptor } from './bridge-daemon.mjs';
12
+ import { bridgeRegistrarSettings } from './bridge-registrar.mjs';
12
13
 
13
14
  export const BRIDGE_LAUNCH_AGENT_LABEL = 'dev.kitepon.lattice.bridge';
14
15
  const START_TIMEOUT_MS = 5_000;
@@ -126,6 +127,17 @@ function plistDocument({ nodePath, bridgePath, instanceToken, env }) {
126
127
  }
127
128
  environment.push(['LATTICE_CONFIG_DIR', env.LATTICE_CONFIG_DIR]);
128
129
  }
130
+ // The daemon registers its upstream on every new binding, but only if it can
131
+ // see the registrar settings. launchd does not inherit the shell environment,
132
+ // so without baking them in here the self-registration silently never fires —
133
+ // which is exactly the kind of quiet non-recovery this whole path exists to
134
+ // remove. `bridgeRegistrarSettings` rejects a half-configured pair rather than
135
+ // installing an agent that would skip registration forever.
136
+ const registrar = bridgeRegistrarSettings(env);
137
+ if (registrar !== null) {
138
+ environment.push(['LATTICE_BRIDGE_REGISTRAR_SSH_HOST', registrar.host]);
139
+ environment.push(['LATTICE_BRIDGE_REGISTRAR_SCRIPT', registrar.script]);
140
+ }
129
141
  const environmentXml = environment.map(([key, value]) =>
130
142
  ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join('\n');
131
143
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Bridge upstream self-registration.
3
+ *
4
+ * The public site is reverse-proxied to a literal LAN address held by another
5
+ * host. When this host's address moves — a DHCP lease change is enough — that
6
+ * literal goes stale and the site starts failing, with nothing on either side
7
+ * noticing. Registration closes that loop: after the bridge binds, it tells the
8
+ * reverse proxy where it actually is.
9
+ *
10
+ * This is an explicit connector, not a hook into someone else's tooling. It
11
+ * runs one fixed shape — `ssh <host> <script> <port>` — over the operator's
12
+ * existing ssh trust, and both operands are validated. Lattice never sends a
13
+ * command string, so the reverse proxy host decides what registration means and
14
+ * can refuse anything it does not like. The remote script is expected to derive
15
+ * the address from the ssh source rather than trusting anything sent to it.
16
+ *
17
+ * Opt-in via environment, so an unconfigured host simply never registers:
18
+ * LATTICE_BRIDGE_REGISTRAR_SSH_HOST ssh destination (an ssh_config alias)
19
+ * LATTICE_BRIDGE_REGISTRAR_SCRIPT absolute path of the remote script
20
+ */
21
+
22
+ import { execFile } from 'node:child_process';
23
+
24
+ export const REGISTRAR_RESULT_SCHEMA = 'lattice.bridge_registrar_result.v1';
25
+
26
+ const SSH_HOST = /^[A-Za-z0-9][A-Za-z0-9._-]{0,253}$/u;
27
+ const REMOTE_SCRIPT = /^\/[A-Za-z0-9._\-/]{1,255}$/u;
28
+ const DEFAULT_TIMEOUT_MS = 15_000;
29
+
30
+ export class BridgeRegistrarError extends Error {
31
+ constructor(code, message, detail = null) {
32
+ super(message);
33
+ this.name = 'BridgeRegistrarError';
34
+ this.code = code;
35
+ this.detail = detail;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Registrar settings, or null when the operator has not opted in.
41
+ * A half-configured registrar is an error rather than a silent no-op: it means
42
+ * someone intended to register and the site will quietly rot if we skip it.
43
+ */
44
+ export function bridgeRegistrarSettings(env = process.env) {
45
+ const host = env.LATTICE_BRIDGE_REGISTRAR_SSH_HOST ?? '';
46
+ const script = env.LATTICE_BRIDGE_REGISTRAR_SCRIPT ?? '';
47
+ if (host === '' && script === '') return null;
48
+ if (!SSH_HOST.test(host)) {
49
+ throw new BridgeRegistrarError('BRIDGE_REGISTRAR_INVALID',
50
+ 'LATTICE_BRIDGE_REGISTRAR_SSH_HOST must be a bare ssh destination', { host });
51
+ }
52
+ if (!REMOTE_SCRIPT.test(script)) {
53
+ throw new BridgeRegistrarError('BRIDGE_REGISTRAR_INVALID',
54
+ 'LATTICE_BRIDGE_REGISTRAR_SCRIPT must be an absolute path without spaces', { script });
55
+ }
56
+ return Object.freeze({ host, script });
57
+ }
58
+
59
+ function runSsh({ host, script, port, timeoutMs, runner }) {
60
+ const args = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host, script, String(port)];
61
+ return new Promise((resolve) => {
62
+ runner('ssh', args, { timeout: timeoutMs, encoding: 'utf8' }, (error, stdout, stderr) => {
63
+ resolve({ error, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
64
+ });
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Register `port` on the reverse proxy host. The address is deliberately NOT
70
+ * sent: the remote end reads it from the ssh connection, so this host can only
71
+ * ever register itself.
72
+ *
73
+ * Never throws for a remote failure — the bridge is still serving locally and
74
+ * taking it down would turn a proxy-config problem into an outage. The failure
75
+ * is returned typed so callers surface it instead of swallowing it.
76
+ */
77
+ export async function registerBridgeUpstream({
78
+ port, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, runner = execFile,
79
+ } = {}) {
80
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
81
+ throw new BridgeRegistrarError('BRIDGE_REGISTRAR_INVALID', 'registration port is invalid', { port });
82
+ }
83
+ const settings = bridgeRegistrarSettings(env);
84
+ if (settings === null) {
85
+ return { schema: REGISTRAR_RESULT_SCHEMA, state: 'not_configured', port,
86
+ host: null, remote: null, detail: null };
87
+ }
88
+ const { error, stdout, stderr } = await runSsh({ ...settings, port, timeoutMs, runner });
89
+ if (error) {
90
+ return { schema: REGISTRAR_RESULT_SCHEMA, state: 'failed', port, host: settings.host,
91
+ remote: null, detail: (stderr.trim() || error.message || 'ssh registration failed').slice(0, 500) };
92
+ }
93
+ let remote = null;
94
+ try { remote = JSON.parse(stdout.trim()); } catch { remote = null; }
95
+ if (remote === null) {
96
+ return { schema: REGISTRAR_RESULT_SCHEMA, state: 'failed', port, host: settings.host,
97
+ remote: null, detail: `registrar returned no parsable result: ${stdout.trim().slice(0, 200)}` };
98
+ }
99
+ return { schema: REGISTRAR_RESULT_SCHEMA,
100
+ state: remote.changed === true ? 'updated' : 'unchanged',
101
+ port, host: settings.host, remote, detail: null };
102
+ }
@@ -5,6 +5,10 @@ import { lstat, open } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { parseTree } from 'jsonc-parser';
7
7
 
8
+ import { networkInterfaces } from 'node:os';
9
+
10
+ import { resolveBridgeListenAddress } from './bridge-address.mjs';
11
+ import { registerBridgeUpstream } from './bridge-registrar.mjs';
8
12
  import {
9
13
  BridgeConfigError, bridgeConfigPaths, normalizeBridgeAllowedHost, readBridgeConfig,
10
14
  } from './bridge-config.mjs';
@@ -184,12 +188,28 @@ export async function startBridgeServer({
184
188
  config, env = process.env,
185
189
  instanceToken = null,
186
190
  resolveUpstream = (upstream) => resolveBridgeUpstream(upstream, { env }),
191
+ interfaces = networkInterfaces(),
187
192
  } = {}) {
188
193
  if (config?.enabled !== true) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
189
194
  if (!Array.isArray(config.allowed_hosts) || config.allowed_hosts.length === 0) {
190
195
  throw new BridgeConfigError('BRIDGE_CONFIG_INVALID', 'bridge allowed hosts are required');
191
196
  }
197
+ // A DHCP lease change moves the host inside its own subnet and strands the
198
+ // configured literal. Follow it rather than binding a dead address, but only
199
+ // within the same subnet (see bridge-address.mjs for why that bound matters).
200
+ const resolvedListen = resolveBridgeListenAddress({ configured: config.listen.address, interfaces });
201
+ if (resolvedListen.effective === null) {
202
+ throw new BridgeConfigError('BRIDGE_LISTEN_ADDRESS_ABSENT',
203
+ 'configured bridge listen address is not present on this host',
204
+ { ...config.listen, listen_state: resolvedListen.state });
205
+ }
206
+ const listenAddress = resolvedListen.effective;
192
207
  let allowedHosts = new Set(config.allowed_hosts);
208
+ // The rebound address has to answer for itself, otherwise every request to it
209
+ // is rejected by the Host allow-list the operator never knew had gone stale.
210
+ if (listenAddress !== config.listen.address) {
211
+ allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
212
+ }
193
213
  let currentConfig = config;
194
214
  const handleRequest = async (incoming, response) => {
195
215
  let requestHost;
@@ -249,17 +269,19 @@ export async function startBridgeServer({
249
269
  });
250
270
  await new Promise((resolve, reject) => {
251
271
  server.once('error', reject);
252
- server.listen({ host: config.listen.address, port: config.listen.port, exclusive: true }, resolve);
272
+ server.listen({ host: listenAddress, port: config.listen.port, exclusive: true }, resolve);
253
273
  }).catch((error) => {
254
274
  throw new BridgeConfigError(error?.code === 'EADDRINUSE' ? 'BRIDGE_PORT_UNAVAILABLE' : 'BRIDGE_BIND_FAILED',
255
- 'bridge listen failed', { ...config.listen }, error);
275
+ 'bridge listen failed', { ...config.listen, effective_address: listenAddress }, error);
256
276
  });
257
277
  const boundAddress = server.address();
258
278
  const actualPort = typeof boundAddress === 'object' && boundAddress !== null
259
279
  ? boundAddress.port : config.listen.port;
260
280
  let closed = false;
261
281
  return Object.freeze({
262
- address: config.listen.address,
282
+ address: listenAddress,
283
+ configured_address: config.listen.address,
284
+ rebound: listenAddress !== config.listen.address,
263
285
  port: actualPort,
264
286
  updateConfig(next) {
265
287
  if (next?.enabled !== true || next.listen.address !== config.listen.address
@@ -268,6 +290,9 @@ export async function startBridgeServer({
268
290
  }
269
291
  currentConfig = next;
270
292
  allowedHosts = new Set(next.allowed_hosts);
293
+ if (listenAddress !== config.listen.address) {
294
+ allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
295
+ }
271
296
  },
272
297
  close: async () => {
273
298
  if (closed) return;
@@ -279,9 +304,27 @@ export async function startBridgeServer({
279
304
  });
280
305
  }
281
306
 
282
- export function bridgeRuntimeController({ env = process.env, instanceToken = null } = {}) {
307
+ export function bridgeRuntimeController({
308
+ env = process.env, instanceToken = null,
309
+ register = registerBridgeUpstream,
310
+ report = (line) => process.stderr.write(`${line}\n`),
311
+ } = {}) {
283
312
  let active = null;
284
313
  let fingerprint = null;
314
+ // A fresh binding is exactly when the reverse proxy's literal may have gone
315
+ // stale, so that is when the bridge tells it where it now is. A registration
316
+ // failure is reported, never swallowed, and never takes the local bridge down:
317
+ // the bridge is still serving, only the published route is behind.
318
+ const announce = async (binding) => {
319
+ try {
320
+ const outcome = await register({ port: binding.port, env });
321
+ if (outcome.state !== 'not_configured') report(JSON.stringify(outcome));
322
+ } catch (error) {
323
+ report(JSON.stringify({ schema: 'lattice.bridge_registrar_result.v1', state: 'failed',
324
+ port: binding.port, host: null, remote: null,
325
+ detail: error?.message ?? 'registration failed' }));
326
+ }
327
+ };
285
328
  return Object.freeze({
286
329
  async reconcile() {
287
330
  const config = await readBridgeConfig({ env });
@@ -293,7 +336,11 @@ export function bridgeRuntimeController({ env = process.env, instanceToken = nul
293
336
  fingerprint = null;
294
337
  return null;
295
338
  }
296
- if (active !== null && active.address === config.listen.address && active.port === config.listen.port) {
339
+ // Compare against the CONFIGURED address: a rebound binding still serves
340
+ // the same configuration, and comparing the effective address would tear
341
+ // the server down and rebuild it on every reconcile.
342
+ if (active !== null && active.configured_address === config.listen.address
343
+ && active.port === config.listen.port) {
297
344
  active.updateConfig(config);
298
345
  fingerprint = next;
299
346
  return active;
@@ -303,6 +350,7 @@ export function bridgeRuntimeController({ env = process.env, instanceToken = nul
303
350
  active = replacement;
304
351
  fingerprint = next;
305
352
  await previous?.close();
353
+ await announce(active);
306
354
  return active;
307
355
  },
308
356
  async close() {
package/src/cli-help.mjs CHANGED
@@ -88,6 +88,10 @@ Commands:
88
88
  reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--allow-host <host>...]
89
89
  status
90
90
  disable
91
+ register # 現在のlisten portをreverse proxy hostへ自己登録する
92
+
93
+ registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPTが
94
+ 両方設定されている時だけ動く。アドレスは送らず、remote側がssh送信元から決める。
91
95
  `,
92
96
  });
93
97
 
@@ -135,6 +139,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
135
139
  'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--allow-host <host>...] --json',
136
140
  'bridge status': 'bridge status --json',
137
141
  'bridge disable': 'bridge disable --json',
142
+ 'bridge register': 'bridge register --json',
138
143
  });
139
144
 
140
145
  function requestedNamespace(argv) {
@@ -6,6 +6,16 @@ import {
6
6
  } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
 
9
+ import packageJson from '../package.json' with { type: 'json' };
10
+
11
+ /**
12
+ * The version of the code in THIS process. A daemon loads its modules once at
13
+ * startup and keeps serving them, so installing a new package does not change
14
+ * what the running daemon serves. The daemon reports the version it started
15
+ * with; anything else is code that has already been replaced on disk.
16
+ */
17
+ export const TODO_DASHBOARD_CODE_VERSION = packageJson.version;
18
+
9
19
  const REGISTRY_SCHEMA = 'lattice.todo_dashboard_registry.v1';
10
20
  const DAEMON_SCHEMA = 'lattice.todo_dashboard_daemon.v1';
11
21
  const DEFAULT_PORT = 0;
@@ -182,7 +192,13 @@ async function daemonAttestation(descriptor, { timeoutMs = 2_000 } = {}) {
182
192
  if (body?.schema !== 'lattice.todo_dashboard_health.v1' || body.pid !== descriptor.pid
183
193
  || !Array.isArray(body.project_ids)) return null;
184
194
  const keys = Object.keys(body).sort().join(',');
185
- if (keys === 'pid,port,project_ids,schema' && body.port === descriptor.port) return 'current';
195
+ // 'legacy' means "alive, but serving code we have already replaced" — the
196
+ // caller starts a replacement and stops it. A daemon that predates the
197
+ // version field, or one still running an older package, is exactly that.
198
+ if (keys === 'pid,port,project_ids,schema,version' && body.port === descriptor.port) {
199
+ return body.version === TODO_DASHBOARD_CODE_VERSION ? 'current' : 'legacy';
200
+ }
201
+ if (keys === 'pid,port,project_ids,schema' && body.port === descriptor.port) return 'legacy';
186
202
  if (keys === 'pid,project_ids,schema') return 'legacy';
187
203
  return null;
188
204
  } catch { return null; }
@@ -6,7 +6,7 @@ import {
6
6
  } from './todo-markdown-renderer.mjs';
7
7
  import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
8
8
 
9
- export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v9';
9
+ export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v10';
10
10
  export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
11
11
  export const TODO_GANTT_HTML_MAX_BYTES = 24 * 1024 * 1024;
12
12
 
@@ -131,14 +131,16 @@ function foldIndex(layout) {
131
131
  return new Map((layout?.folded ?? []).map((entry) => [refKey(entry.task), refKey(entry.fold)]));
132
132
  }
133
133
 
134
- function renderTaskIndexEntry(section, lookup, folds) {
134
+ function renderTaskIndexEntry(section, lookup) {
135
135
  const key = refKey(section.ref);
136
136
  const status = DOCUMENT_STATUS[section.state.status] ?? { mark: '?', label: '状態不明' };
137
137
  const blockedReason = section.state.status === 'blocked'
138
138
  ? `<span class="task-index-blocked-reason">— ${escapeHtmlText(section.state.blocked_reason ?? '理由未記録')}</span>` : '';
139
- // A folded ToDo keeps its row here — the index is the complete list — but
140
- // selecting it points at the fold node that actually stands on the diagram.
141
- const selectKey = folds.get(key) ?? key;
139
+ // A folded ToDo keeps its row here — the index is the complete list — and it
140
+ // selects its OWN detail. Pointing the row at the fold node standing in for it
141
+ // on the diagram would open nothing: a fold node is not a ToDo, so no detail
142
+ // panel carries its key.
143
+ const selectKey = key;
142
144
  return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(selectKey)}"><span class="task-index-status status-${escapeHtmlAttribute(section.state.status)}" role="img" aria-label="${escapeHtmlAttribute(status.label)}">${escapeHtmlText(status.mark)}</span><span class="task-index-reference">${escapeHtmlText(taskReference(section, lookup))}</span><strong>${escapeHtmlText(section.task.title)}</strong>${blockedReason}</button></li>`;
143
145
  }
144
146
 
@@ -156,13 +158,33 @@ function renderTaskIndex(sections, lookup, folds = new Map()) {
156
158
  const drawn = plan.tasks.filter((section) => !folds.has(refKey(section.ref)));
157
159
  const folded = plan.tasks.filter((section) => folds.has(refKey(section.ref)));
158
160
  const drawnList = drawn.length === 0 ? ''
159
- : `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol>`;
161
+ : `<ol class="task-index-list">${drawn.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol>`;
160
162
  const foldedList = folded.length === 0 ? ''
161
- : `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup, folds)).join('')}</ol></details>`;
163
+ : `<details class="task-index-folded"><summary>完走済みとして畳んだ工程 ${folded.length}件</summary><ol class="task-index-list">${folded.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol></details>`;
162
164
  return `<section class="task-index-plan"><h2><code>${escapeHtmlText(plan.planKey)}</code></h2>${drawnList}${foldedList}</section>`;
163
165
  }).join('');
164
166
  }
165
167
 
168
+ /**
169
+ * Detail panel for a fold node.
170
+ *
171
+ * A fold node is the only thing standing on the diagram for the history it
172
+ * summarises, so it must open like any other node — otherwise clicking the
173
+ * folded part of the plan does nothing and the folded ToDos become unreachable.
174
+ */
175
+ function renderFoldDetail(fold, members, lookup) {
176
+ const laneLabels = fold.lanes.map((lane) => {
177
+ const entry = lookup.lanes.get(JSON.stringify([fold.ref.plan_key, lane]));
178
+ return entry === undefined ? lane : `${lane} — ${entry.name}`;
179
+ });
180
+ const chain = fold.longest_chain_task_count === 0 ? ''
181
+ : `<p><strong>構造上の最長依存鎖:</strong> このうち${fold.longest_chain_task_count}工程が乗っています。</p>`;
182
+ const memberList = members.length === 0
183
+ ? '<p class="relation-empty">構成工程を復元できませんでした。</p>'
184
+ : `<ol class="task-index-list">${members.map((section) => renderTaskIndexEntry(section, lookup)).join('')}</ol>`;
185
+ return `<article class="task-detail fold-detail" data-detail-key="${escapeHtmlAttribute(refKey(fold.ref))}" hidden><header><span class="detail-status status-done">▣ 完走済み(畳み込み)</span><span class="detail-reference">${escapeHtmlText(`${fold.task_count}工程`)}</span></header><h1>完了済み ${escapeHtmlText(String(fold.task_count))}件</h1><p><strong>plan:</strong> <code>${escapeHtmlText(fold.ref.plan_key)}</code></p><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(laneLabels.join('、'))}</p>${chain}<p class="fold-note">後続に作業中・未着手の工程が残っていないため、まとめて1個のノードとして描いています。図に全件を描くには <code>lattice todo gantt --scope all</code> を実行してください。</p><section><h2>含まれる工程 ${escapeHtmlText(String(members.length))}件</h2>${memberList}</section></article>`;
186
+ }
187
+
166
188
  function presentationLookup(presentation) {
167
189
  return {
168
190
  lanes: new Map((presentation?.lanes ?? []).map((lane) => [JSON.stringify([lane.plan_key, lane.lane]), lane])),
@@ -175,14 +197,19 @@ function taskReference(section, lookup) {
175
197
  return number === undefined ? `ID ${section.task.task_id}` : `工程 ${number.display_number}`;
176
198
  }
177
199
 
178
- function renderRelationList(relations, sectionByKey, lookup, emptyText) {
200
+ function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new Map()) {
179
201
  if (relations.length === 0) return `<p class="relation-empty">${escapeHtmlText(emptyText)}</p>`;
180
202
  return `<ul class="relation-list">${relations.map((relation) => {
181
- const target = sectionByKey.get(refKey(relation.ref));
203
+ const targetKey = refKey(relation.ref);
204
+ const target = sectionByKey.get(targetKey);
182
205
  if (target === undefined) return '';
183
206
  const join = relation.joinIds.length === 0 ? ''
184
207
  : `<span class="relation-kind">合流条件: ${escapeHtmlText(relation.joinIds.join(', '))}</span>`;
185
- return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(refKey(target.ref))}"><strong>${escapeHtmlText(taskReference(target, lookup))}</strong><span>${escapeHtmlText(target.task.title)}</span></button>${join}</li>`;
208
+ // Mark the ones the diagram no longer draws separately, so the reader knows
209
+ // why they cannot find this box on screen.
210
+ const reference = folds.has(targetKey)
211
+ ? `▣ ${taskReference(target, lookup)}` : taskReference(target, lookup);
212
+ return `<li><button type="button" data-select-node-key="${escapeHtmlAttribute(targetKey)}"><strong>${escapeHtmlText(reference)}</strong><span>${escapeHtmlText(target.task.title)}</span></button>${join}</li>`;
186
213
  }).join('')}</ul>`;
187
214
  }
188
215
 
@@ -211,6 +238,7 @@ function renderRightPane(sections, layout, presentation, readModel) {
211
238
  const lookup = presentationLookup(presentation);
212
239
  const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
213
240
  const nodeByKey = new Map(layout.nodes.map((node) => [refKey(node.ref), node]));
241
+ const folds = foldIndex(layout);
214
242
  const incoming = new Map(sections.map((section) => [refKey(section.ref), []]));
215
243
  const outgoing = new Map(sections.map((section) => [refKey(section.ref), []]));
216
244
  const addRelation = (relations, ownerKey, ref, joinIds) => {
@@ -223,7 +251,10 @@ function renderRightPane(sections, layout, presentation, readModel) {
223
251
  }
224
252
  entry.joinIds = [...new Set([...entry.joinIds, ...joinIds])].sort();
225
253
  };
226
- for (const edge of layout.edges) {
254
+ // Premises and successors come from the FULL graph. `layout.edges` is the
255
+ // drawn graph, where a fold unit's interior dependencies have been contracted
256
+ // away — reading those here would tell a folded ToDo it has no premises.
257
+ for (const edge of layout.full_edges ?? layout.edges) {
227
258
  addRelation(incoming, refKey(edge.to), edge.from, edge.join_ids);
228
259
  addRelation(outgoing, refKey(edge.from), edge.to, edge.join_ids);
229
260
  }
@@ -256,10 +287,25 @@ function renderRightPane(sections, layout, presentation, readModel) {
256
287
  const readiness = node?.visibility.next_ready
257
288
  ? `<p class="readiness-note">ready frontierの一員です。${ready.length > 1 ? '他のready工程と同時dispatchするのが既定です。subsetだけを選ぶ場合は理由を記録してください。' : '現在の唯一の着手候補です。'}</p>`
258
289
  : incoming.get(key).length === 0 ? '<p class="readiness-note">登録済みの前提工程はありません。図だけではdispatch可否を判定しません。</p>' : '';
259
- return `<article class="task-detail" data-detail-key="${escapeHtmlAttribute(key)}" hidden><header><span class="detail-status status-${escapeHtmlAttribute(section.state.status)}">${escapeHtmlText(status.mark)} ${escapeHtmlText(status.label)}</span><span class="detail-reference">${escapeHtmlText(taskReference(section, lookup))}</span></header><h1>${escapeHtmlText(section.task.title)}</h1><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(category)}</p>${categoryDescription}<p><strong>正規ID:</strong> <code>${escapeHtmlText(`${section.ref.plan_key}/${section.task.task_id}`)}</code></p>${blockedReason}${readiness}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。')}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。')}</section><p class="anchor-status">${escapeHtmlText(anchorText)}</p><details class="task-diagnostics"><summary>開発者向け診断</summary><dl><dt>canonical ref</dt><dd><code>${escapeHtmlText(`${section.ref.project_id}/${section.ref.plan_key}/${section.task.task_id}`)}</code></dd><dt>anchor</dt><dd>${escapeHtmlText(section.anchorOutcome.anchored ? 'verified' : section.anchorOutcome.reason)}</dd></dl></details></article>`;
290
+ // A folded ToDo has no box of its own on the diagram. Say which fold node
291
+ // stands in for it, and make that node one click away.
292
+ const foldKey = folds.get(key);
293
+ const foldedNote = foldKey === undefined ? ''
294
+ : `<p class="fold-note">この工程は図の上では ▣ 畳み込みノードにまとめられています。<button type="button" class="fold-return" data-select-node-key="${escapeHtmlAttribute(foldKey)}">畳み込みノードを開く</button></p>`;
295
+ return `<article class="task-detail" data-detail-key="${escapeHtmlAttribute(key)}" hidden><header><span class="detail-status status-${escapeHtmlAttribute(section.state.status)}">${escapeHtmlText(status.mark)} ${escapeHtmlText(status.label)}</span><span class="detail-reference">${escapeHtmlText(taskReference(section, lookup))}</span></header><h1>${escapeHtmlText(section.task.title)}</h1><p class="detail-category"><strong>カテゴリ:</strong> ${escapeHtmlText(category)}</p>${categoryDescription}<p><strong>正規ID:</strong> <code>${escapeHtmlText(`${section.ref.plan_key}/${section.task.task_id}`)}</code></p>${blockedReason}${readiness}${foldedNote}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</section><p class="anchor-status">${escapeHtmlText(anchorText)}</p><details class="task-diagnostics"><summary>開発者向け診断</summary><dl><dt>canonical ref</dt><dd><code>${escapeHtmlText(`${section.ref.project_id}/${section.ref.plan_key}/${section.task.task_id}`)}</code></dd><dt>anchor</dt><dd>${escapeHtmlText(section.anchorOutcome.anchored ? 'verified' : section.anchorOutcome.reason)}</dd></dl></details></article>`;
260
296
  }).join('');
261
- const taskIndex = renderTaskIndex(sections, lookup, foldIndex(layout));
262
- return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>元Markdown全文</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
297
+ const membersByFold = new Map();
298
+ for (const [taskKey, foldKey] of folds) {
299
+ const section = sectionByKey.get(taskKey);
300
+ if (section === undefined) continue;
301
+ if (!membersByFold.has(foldKey)) membersByFold.set(foldKey, []);
302
+ membersByFold.get(foldKey).push(section);
303
+ }
304
+ const foldDetails = (layout.scope?.folds ?? [])
305
+ .map((fold) => renderFoldDetail(fold, membersByFold.get(refKey(fold.ref)) ?? [], lookup))
306
+ .join('');
307
+ const taskIndex = renderTaskIndex(sections, lookup, folds);
308
+ return `<div class="right-toolbar"><button type="button" data-show-overview>概要</button><button type="button" data-show-selected hidden>選択工程へ戻る</button><button type="button" data-show-task-index>元Markdown全文</button></div><div class="right-content">${overview}<div data-right-panel="details" hidden>${details}${foldDetails}</div><section class="task-index" data-right-panel="task-index" hidden><h1>全工程</h1><p>Latticeに登録された全工程を、現在の状態とともに登録順で表示しています。</p>${taskIndex}</section></div>`;
263
309
  }
264
310
 
265
311
  function renderDiagramLegend(presentation, layout = null) {
@@ -327,6 +373,9 @@ body{display:grid;grid-template-rows:minmax(0,1fr);height:100vh;margin:0;backgro
327
373
  .task-index-list{margin:0;padding:0;list-style:none}.task-index-list li+li{margin-top:8px}.task-index-list button{display:grid;width:100%;grid-template-columns:1.5rem auto minmax(0,1fr);gap:4px 8px;align-items:baseline}
328
374
  .task-index-status{grid-row:1 / span 2;color:var(--text-secondary);font-size:13.5px;text-align:center}.task-index-status.status-in-progress{color:var(--accent)}.task-index-status.status-done{color:var(--good)}.task-index-status.status-blocked{color:var(--critical)}
329
375
  .task-index-reference{color:var(--text-secondary);font-size:12px;white-space:nowrap}.task-index-list strong{font-size:13.5px;font-weight:600;overflow-wrap:anywhere}.task-index-blocked-reason{grid-column:2 / -1;color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}
376
+ .fold-detail>section{margin-top:16px}.fold-detail h2{margin:0 0 12px;font-size:16px;font-weight:600}
377
+ .fold-return{margin-left:8px;padding:2px 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface-2);color:var(--text-primary);font:500 12px/1.6 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif;cursor:pointer}
378
+ .fold-return:focus-visible{outline:2px solid var(--text-primary);outline-offset:2px}
330
379
  .todo-gantt text{font-family:system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif;pointer-events:none}
331
380
  .todo-node .node-surface{fill:var(--surface-2);stroke:var(--border);stroke-width:1}
332
381
  .todo-node .node-meta{fill:var(--text-secondary);font-size:12px;font-weight:500}
@@ -403,7 +403,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
403
403
 
404
404
  // Only the geometry stage below sees the narrowed graph.
405
405
  const projected = scope === 'all'
406
- ? { nodes: full.nodes, edges: full.edges, foldedByKey: new Map(), folds: [], refined: false }
406
+ ? { nodes: full.nodes, edges: full.edges, foldedByKey: new Map(), folds: [], grouping: null }
407
407
  : projectTodoGanttScope({
408
408
  nodes: full.nodes, edges: full.edges, wave: fullWaves.wave, longestChainKeys: longestNodeKeys,
409
409
  });
@@ -619,6 +619,16 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
619
619
  },
620
620
  nodes: projectedNodes,
621
621
  edges: projectedEdges,
622
+ // Every dependency in the plan, before folding contracted any of them away.
623
+ // The diagram draws `edges`; anything that describes a ToDo in words — the
624
+ // premises and successors in the right pane — reads this instead, so a
625
+ // folded ToDo keeps telling the truth about what it depended on.
626
+ full_edges: full.edges.map((edge) => ({
627
+ from: { ...full.nodesByKey.get(edge.from).ref },
628
+ to: { ...full.nodesByKey.get(edge.to).ref },
629
+ kinds: [...edge.kinds].sort(compareText),
630
+ join_ids: [...edge.joinIdentities.values()].map(({ join_id }) => join_id).sort(compareText),
631
+ })),
622
632
  connectors: junctionConnectors,
623
633
  groups: {
624
634
  plans: [...planMap.entries()].map(([plan_key, task_count]) => ({ plan_key, task_count })),
@@ -628,7 +638,7 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
628
638
  requested: scope,
629
639
  folded_task_count: projected.foldedByKey.size,
630
640
  fold_node_count: projected.folds.length,
631
- per_wave_refinement: projected.refined,
641
+ grouping: projected.grouping,
632
642
  folds: projected.folds.map((entry) => ({
633
643
  ref: { ...entry.ref },
634
644
  task_count: entry.task_count,
@@ -1,5 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
 
3
+ import { TODO_DASHBOARD_CODE_VERSION } from './todo-dashboard-registry.mjs';
4
+
3
5
  const LOOPBACK = '127.0.0.1';
4
6
  const POLL_MS = 500;
5
7
  const HTTP_ERROR_SCHEMA = 'lattice.todo_gantt_http_error.v1';
@@ -136,8 +138,12 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
136
138
  if (url.pathname === '/__lattice/health') {
137
139
  response.writeHead(200, { 'content-type': 'application/json; charset=utf-8',
138
140
  'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
141
+ // `version` is the package this process loaded at startup, not the one
142
+ // installed on disk. That difference is the whole point: it is how a
143
+ // caller learns the daemon is serving code that has been superseded.
139
144
  response.end(`${JSON.stringify({ schema: 'lattice.todo_dashboard_health.v1', pid: process.pid,
140
- port: actualPort, project_ids: registry.list().map(({ projectId }) => projectId) })}\n`);
145
+ port: actualPort, project_ids: registry.list().map(({ projectId }) => projectId),
146
+ version: TODO_DASHBOARD_CODE_VERSION })}\n`);
141
147
  return;
142
148
  }
143
149
  if (url.pathname === '/' || url.pathname === '/projects/') {
@@ -10,10 +10,16 @@
10
10
  * where that projection narrows.
11
11
  *
12
12
  * The rule is "fold the dead branches": a completed ToDo that no longer leads
13
- * to any live work is history, and history collapses into one labelled node
14
- * per finished branch. Completed ToDos that are still the direct premise of
15
- * live work stay visible, because they are the context for what is dispatchable
16
- * right now.
13
+ * to any live work is history, and history collapses into as few labelled nodes
14
+ * as the graph allows one per plan when nothing forbids it. Completed ToDos
15
+ * that are still the direct premise of live work stay visible, because they are
16
+ * the context for what is dispatchable right now.
17
+ *
18
+ * Grouping by connectivity was tried first and does not compress real plans: a
19
+ * store whose finished ToDos rarely declare dependencies on each other yields
20
+ * one fold unit per ToDo, which draws the same number of boxes as no folding at
21
+ * all while hiding every title. History is grouped by the plan it belongs to,
22
+ * not by whether its members happen to be wired together.
17
23
  *
18
24
  * This module is pure graph math over the layout's internal node/edge shape.
19
25
  * It must run AFTER dependency waves, the longest dependency chain and the
@@ -89,50 +95,59 @@ function foldableKeys(nodes, distance, foldDistance) {
89
95
  }
90
96
 
91
97
  /**
92
- * Group foldable nodes into fold units.
98
+ * Grouping strategies, coarsest first. The projection takes the first one whose
99
+ * contraction stays acyclic.
100
+ *
101
+ * - `plan`: one fold unit per plan. All history of a finished plan becomes one
102
+ * node. This is the target shape and it is what a reader wants to see.
103
+ * - `plan_stage`: additionally split per kept-node depth. A cycle can only close
104
+ * through a node kept on screen, and that axis is exactly what `keptDepth`
105
+ * measures, so this is the smallest refinement that removes the usual cause.
106
+ * - `plan_wave`: additionally split per dependency wave. Provably acyclic —
107
+ * every dependency edge strictly increases the wave, so a contracted edge
108
+ * between two units always increases it too and no cycle can exist.
109
+ */
110
+ const GROUPING_LADDER = Object.freeze(['plan', 'plan_stage', 'plan_wave']);
111
+
112
+ /**
113
+ * Number of kept (non-foldable) nodes lying before each node on the longest
114
+ * path. Crossing a kept node strictly increases it; edges between two foldable
115
+ * nodes leave it unchanged.
93
116
  *
94
- * `byComponent` groups each weakly connected component of the foldable subgraph
95
- * into one unit the compact projection, one node per finished branch.
96
- * `byComponentAndWave` additionally splits each component per dependency wave.
97
- * The latter is always acyclic once contracted (every dependency edge strictly
98
- * increases the wave, so contracted edges do too), which makes it the
99
- * guaranteed-safe refinement when contraction would otherwise close a cycle.
117
+ * Edges always increase the wave (`assignWaves` is a longest-path layering), so
118
+ * visiting nodes in ascending wave order settles every predecessor first.
100
119
  */
101
- function groupFoldable(nodes, edges, foldable, wave, splitByWave) {
102
- const parent = new Map([...foldable].map((key) => [key, key]));
103
- const find = (key) => {
104
- let root = key;
105
- while (parent.get(root) !== root) root = parent.get(root);
106
- let cursor = key;
107
- while (parent.get(cursor) !== root) {
108
- const next = parent.get(cursor);
109
- parent.set(cursor, root);
110
- cursor = next;
120
+ function keptDepth(nodes, edges, foldable, wave) {
121
+ const incoming = new Map(nodes.map(({ key }) => [key, []]));
122
+ for (const edge of edges) incoming.get(edge.to).push(edge.from);
123
+ const depth = new Map();
124
+ const ordered = [...nodes].sort((left, right) => wave.get(left.key) - wave.get(right.key)
125
+ || compareText(left.key, right.key));
126
+ for (const node of ordered) {
127
+ let best = 0;
128
+ for (const predecessor of incoming.get(node.key)) {
129
+ const settled = depth.get(predecessor);
130
+ if (settled === undefined) continue;
131
+ const candidate = settled + (foldable.has(predecessor) ? 0 : 1);
132
+ if (candidate > best) best = candidate;
111
133
  }
112
- return root;
113
- };
114
- const union = (left, right) => {
115
- const leftRoot = find(left);
116
- const rightRoot = find(right);
117
- if (leftRoot === rightRoot) return;
118
- if (compareText(leftRoot, rightRoot) <= 0) parent.set(rightRoot, leftRoot);
119
- else parent.set(leftRoot, rightRoot);
120
- };
121
- const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
122
- for (const edge of edges) {
123
- if (!foldable.has(edge.from) || !foldable.has(edge.to)) continue;
124
- // A fold unit never spans plans: the diagram groups by plan and a unit that
125
- // straddled two plans would have no honest lane to sit in.
126
- if (nodeByKey.get(edge.from).ref.plan_key !== nodeByKey.get(edge.to).ref.plan_key) continue;
127
- union(edge.from, edge.to);
134
+ depth.set(node.key, best);
128
135
  }
136
+ return depth;
137
+ }
129
138
 
139
+ /** Group foldable nodes into fold units under one of `GROUPING_LADDER`. */
140
+ function groupFoldable(nodes, foldable, strategy, axes) {
141
+ const nodeByKey = new Map(nodes.map((node) => [node.key, node]));
142
+ // A fold unit never spans plans: the diagram groups by plan and a unit that
143
+ // straddled two plans would have no honest lane to sit in.
144
+ const axisOf = (key) => (strategy === 'plan' ? null
145
+ : strategy === 'plan_stage' ? axes.stage.get(key)
146
+ : axes.wave.get(key));
130
147
  const unitByNode = new Map();
131
148
  const members = new Map();
132
149
  for (const key of [...foldable].sort(compareText)) {
133
- const unitKey = splitByWave
134
- ? JSON.stringify([find(key), wave.get(key)])
135
- : JSON.stringify([find(key), null]);
150
+ const unitKey = JSON.stringify([nodeByKey.get(key).ref.plan_key, axisOf(key)]);
136
151
  unitByNode.set(key, unitKey);
137
152
  if (!members.has(unitKey)) members.set(unitKey, []);
138
153
  members.get(unitKey).push(key);
@@ -249,15 +264,14 @@ function isAcyclic(nodes, edges) {
249
264
  /**
250
265
  * Project the full dependency graph onto the `live` scope.
251
266
  *
252
- * Contracting a weakly connected component can close a cycle: with a fold
253
- * distance of 1 the shape `f1 -> s -> f2` is reachable, where `s` is a
254
- * completed ToDo kept as the direct premise of live work and `f1`/`f2` belong
255
- * to the same fold unit. Contracting that unit would produce
256
- * `summary -> s -> summary`. So the contraction is verified, and on a cycle the
257
- * grouping is refined to (component, wave) provably acyclic, because every
258
- * dependency edge strictly increases the wave.
267
+ * Contraction can close a cycle: with a fold distance of 1 the shape
268
+ * `f1 -> s -> f2` is reachable, where `s` is a completed ToDo kept as the direct
269
+ * premise of live work and `f1`/`f2` land in the same fold unit. Contracting
270
+ * that unit would produce `summary -> s -> summary`. So each candidate grouping
271
+ * is verified and the coarsest acyclic one wins. The last rung is provably
272
+ * acyclic, so the throw below is a backstop, never a routine outcome.
259
273
  *
260
- * @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, refined: boolean}}
274
+ * @returns {{nodes: Array, edges: Array, foldedByKey: Map, folds: Array, grouping: string}}
261
275
  */
262
276
  export function projectTodoGanttScope({
263
277
  nodes, edges, wave, longestChainKeys = new Set(), foldDistance = DEFAULT_FOLD_DISTANCE,
@@ -265,24 +279,25 @@ export function projectTodoGanttScope({
265
279
  const distance = distanceToLive(nodes, edges, wave);
266
280
  const foldable = foldableKeys(nodes, distance, foldDistance);
267
281
  if (foldable.size === 0) {
268
- return { nodes, edges, foldedByKey: new Map(), folds: [], refined: false };
282
+ return { nodes, edges, foldedByKey: new Map(), folds: [], grouping: null };
269
283
  }
270
284
 
271
- let refined = false;
272
- let grouping = groupFoldable(nodes, edges, foldable, wave, false);
273
- let summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
274
- let contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
275
- if (!isAcyclic(contracted.nodes, contracted.edges)) {
276
- refined = true;
277
- grouping = groupFoldable(nodes, edges, foldable, wave, true);
278
- summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
279
- contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
280
- if (!isAcyclic(contracted.nodes, contracted.edges)) {
281
- throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
282
- 'todo gantt scope contraction produced a cycle after per-wave refinement',
283
- { fold_unit_count: grouping.members.size });
284
- }
285
+ const axes = { wave, stage: keptDepth(nodes, edges, foldable, wave) };
286
+ let selected = null;
287
+ for (const strategy of GROUPING_LADDER) {
288
+ const grouping = groupFoldable(nodes, foldable, strategy, axes);
289
+ const summaryByUnit = buildSummaryNodes(nodes, grouping.members, longestChainKeys);
290
+ const contracted = contract(nodes, edges, grouping.unitByNode, summaryByUnit);
291
+ if (!isAcyclic(contracted.nodes, contracted.edges)) continue;
292
+ selected = { strategy, grouping, summaryByUnit, contracted };
293
+ break;
285
294
  }
295
+ if (selected === null) {
296
+ throw new TodoGanttScopeError('TODO_SCOPE_CONTRACTION_CYCLIC',
297
+ 'todo gantt scope contraction produced a cycle under every grouping',
298
+ { attempted_groupings: [...GROUPING_LADDER] });
299
+ }
300
+ const { grouping, summaryByUnit, contracted } = selected;
286
301
 
287
302
  const foldedByKey = new Map();
288
303
  for (const [nodeKey, unitKey] of grouping.unitByNode) {
@@ -292,5 +307,7 @@ export function projectTodoGanttScope({
292
307
  .sort((left, right) => compareText(left.key, right.key))
293
308
  .map((summary) => ({ ref: { ...summary.ref }, ...summary.fold }));
294
309
 
295
- return { nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, refined };
310
+ return {
311
+ nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, grouping: selected.strategy,
312
+ };
296
313
  }