@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 +2 -2
- package/src/bridge-address.mjs +107 -0
- package/src/bridge-cli.mjs +78 -8
- package/src/bridge-launch-agent.mjs +12 -0
- package/src/bridge-registrar.mjs +102 -0
- package/src/bridge-server.mjs +53 -5
- package/src/cli-help.mjs +5 -0
- package/src/todo-dashboard-registry.mjs +17 -1
- package/src/todo-gantt-html.mjs +63 -14
- package/src/todo-gantt-layout.mjs +12 -2
- package/src/todo-gantt-live.mjs +7 -1
- package/src/todo-gantt-scope.mjs +81 -64
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.12.
|
|
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
|
+
}
|
package/src/bridge-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/bridge-server.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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; }
|
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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 —
|
|
140
|
-
//
|
|
141
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
|
|
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: [],
|
|
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
|
-
|
|
641
|
+
grouping: projected.grouping,
|
|
632
642
|
folds: projected.folds.map((entry) => ({
|
|
633
643
|
ref: { ...entry.ref },
|
|
634
644
|
task_count: entry.task_count,
|
package/src/todo-gantt-live.mjs
CHANGED
|
@@ -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)
|
|
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/') {
|
package/src/todo-gantt-scope.mjs
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
* live work stay visible, because they are
|
|
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
|
-
*
|
|
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
|
-
*
|
|
95
|
-
*
|
|
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
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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,
|
|
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: [],
|
|
282
|
+
return { nodes, edges, foldedByKey: new Map(), folds: [], grouping: null };
|
|
269
283
|
}
|
|
270
284
|
|
|
271
|
-
|
|
272
|
-
let
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 {
|
|
310
|
+
return {
|
|
311
|
+
nodes: contracted.nodes, edges: contracted.edges, foldedByKey, folds, grouping: selected.strategy,
|
|
312
|
+
};
|
|
296
313
|
}
|