@quolu/lattice 0.69.1 → 0.69.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lattice-bridge.mjs +13 -8
- package/docs/bridge-setup.md +12 -0
- package/package.json +1 -1
- package/src/bridge-cli.mjs +14 -0
- package/src/bridge-config.mjs +6 -1
- package/src/bridge-daemon.mjs +86 -26
- package/src/bridge-hub-heartbeat.mjs +2 -2
- package/src/bridge-launch-agent.mjs +5 -5
- package/src/bridge-server.mjs +2 -1
- package/src/bridge-startup-folder.mjs +5 -5
- package/src/runtime-pull-intake.mjs +26 -5
package/bin/lattice-bridge.mjs
CHANGED
|
@@ -33,6 +33,7 @@ const controller = bridgeRuntimeController({ env, instanceToken,
|
|
|
33
33
|
let timer;
|
|
34
34
|
let closing = false;
|
|
35
35
|
let descriptorFingerprint = null;
|
|
36
|
+
let heartbeatBinding = null;
|
|
36
37
|
let checking = false;
|
|
37
38
|
let failClosedError = null;
|
|
38
39
|
let hubHeartbeatError = null;
|
|
@@ -53,9 +54,9 @@ try {
|
|
|
53
54
|
}
|
|
54
55
|
const config = await readBridgeConfig({ env });
|
|
55
56
|
if (config === null || !config.enabled) throw Object.assign(new Error('bridge disabled'), { code: 'BRIDGE_DISABLED' });
|
|
56
|
-
await controller.reconcile();
|
|
57
|
-
await writeBridgeDaemonDescriptor({ config, env });
|
|
58
|
-
descriptorFingerprint = config.updated_at;
|
|
57
|
+
const binding = await controller.reconcile();
|
|
58
|
+
await writeBridgeDaemonDescriptor({ config, binding, env });
|
|
59
|
+
descriptorFingerprint = JSON.stringify([config.updated_at, binding.address, binding.port]);
|
|
59
60
|
} catch (error) {
|
|
60
61
|
process.stderr.write(`${JSON.stringify({ schema: 'lattice.bridge_daemon_error.v1',
|
|
61
62
|
code: error?.code ?? 'BRIDGE_DAEMON_FAILED', message: error?.message ?? 'bridge daemon failed' })}\n`);
|
|
@@ -126,11 +127,12 @@ timer = setInterval(async () => {
|
|
|
126
127
|
await removeBridgeDaemonActiveMarker({ env });
|
|
127
128
|
process.exit(0);
|
|
128
129
|
}
|
|
129
|
-
await controller.reconcile();
|
|
130
|
+
const binding = await controller.reconcile();
|
|
130
131
|
failClosedError = null;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
const nextDescriptorFingerprint = JSON.stringify([config.updated_at, binding.address, binding.port]);
|
|
133
|
+
if (descriptorFingerprint !== nextDescriptorFingerprint) {
|
|
134
|
+
await writeBridgeDaemonDescriptor({ config, binding, env });
|
|
135
|
+
descriptorFingerprint = nextDescriptorFingerprint;
|
|
134
136
|
}
|
|
135
137
|
// Hub heartbeat failures are reported but never fail-close local traffic:
|
|
136
138
|
// an unreachable hub is a routing problem for the hub's aggregate view,
|
|
@@ -139,7 +141,10 @@ timer = setInterval(async () => {
|
|
|
139
141
|
// throws (sendBridgeHubHeartbeat's contract) — only a local error (e.g.
|
|
140
142
|
// the terminal identity file) reaches this catch.
|
|
141
143
|
try {
|
|
142
|
-
const
|
|
144
|
+
const bindingFingerprint = JSON.stringify([binding.address, binding.port]);
|
|
145
|
+
const forceHeartbeat = heartbeatBinding !== bindingFingerprint;
|
|
146
|
+
heartbeatBinding = bindingFingerprint;
|
|
147
|
+
const heartbeat = await hubHeartbeat.tick({ config, force: forceHeartbeat });
|
|
143
148
|
// 直前に書いた指紋は、健全な状態へ戻った時にだけ捨てる。判定より前に捨てると
|
|
144
149
|
// 比較相手が毎周期nullになり、同じ結果をpoll周期ごとに書き続ける。stderrの
|
|
145
150
|
// 消費者が居ない常駐(WindowsのStartup launcher、LaunchAgent)では、この
|
package/docs/bridge-setup.md
CHANGED
|
@@ -64,6 +64,18 @@ fileを除去し、JSON結果の`recovery`へ処置を明示する。その後
|
|
|
64
64
|
自動化・隔離testではabsoluteな`LATTICE_CONFIG_DIR`で設定rootを変更できる。無効な設定、低いport、
|
|
65
65
|
使用中の明示port、危険なrequest target、到達不能upstreamはsilent fallbackせずtyped errorを返す。
|
|
66
66
|
|
|
67
|
+
## DHCPによるIP変更への自動追従
|
|
68
|
+
|
|
69
|
+
設定したIPが端末から消え、同一subnetに次のIPがある場合、bridgeは起動時にも稼働中にも
|
|
70
|
+
そのIPへ自動で待受を移す。設定ファイルのIPを書き換えたり、利用者がdisableとreconfigureを
|
|
71
|
+
順番に実行したりする必要はない。設定のIPは公開するnetworkの意図を保持し、稼働記録と
|
|
72
|
+
health応答は実際の待受IPを示す。生存確認も実際の待受を使い、正常なbridgeを再起動しない。
|
|
73
|
+
|
|
74
|
+
Hubへ接続している場合は、待受の変更に合わせて登録を更新する。Hubは登録接続の送信元IPを
|
|
75
|
+
使うため、端末が新しいIPを申告する独自経路は設けない。配信の確認結果は従来どおり
|
|
76
|
+
`lattice.dashboard_delivery.v1`で返す。別subnetやloopbackへの自動切替は行わず、同一subnetに
|
|
77
|
+
移動先がない場合は`BRIDGE_LISTEN_ADDRESS_ABSENT`を返す。
|
|
78
|
+
|
|
67
79
|
## 常駐が黙って死んでいないか確かめる
|
|
68
80
|
|
|
69
81
|
`reachable`は「設定したaddressで誰かが応答しているか」しか答えない。常駐設定(macOSのLaunchAgent、
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.69.
|
|
3
|
+
"version": "0.69.3",
|
|
4
4
|
"description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Quo / クオ at kitepon.dev",
|
package/src/bridge-cli.mjs
CHANGED
|
@@ -405,6 +405,19 @@ export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
|
|
|
405
405
|
await clearStop({ env });
|
|
406
406
|
const current = await readBridgeConfig({ env });
|
|
407
407
|
const agentSnapshot = await launchAgent.snapshot({ env });
|
|
408
|
+
let activeBinding = null;
|
|
409
|
+
// DHCP追従済みdaemonの実socketを、手動で設定IPへ昇格させる場合がある。
|
|
410
|
+
// tokenでattest済みのdescriptorだけが、この同一portの予約を省略できる。
|
|
411
|
+
// それ以外の占有は従来どおりBRIDGE_PORT_UNAVAILABLEで止める。
|
|
412
|
+
if (command === 'reconfigure' && options.address !== undefined && options.port === undefined
|
|
413
|
+
&& current?.enabled === true && current.listen.port !== undefined) {
|
|
414
|
+
const descriptor = await readBridgeDaemonDescriptor({ env });
|
|
415
|
+
const runtime = descriptor === null ? null : await runtimeIdentity({ env });
|
|
416
|
+
if (runtime?.state === 'running' && descriptor.address === options.address
|
|
417
|
+
&& descriptor.port === current.listen.port) {
|
|
418
|
+
activeBinding = { address: descriptor.address, port: descriptor.port };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
408
421
|
const configured = await configureBridge({
|
|
409
422
|
address: options.address ?? current?.listen.address,
|
|
410
423
|
port: options.port === undefined ? (command === 'reconfigure' ? current?.listen.port ?? null : null) : options.port,
|
|
@@ -413,6 +426,7 @@ export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
|
|
|
413
426
|
allowedHosts: options.allowedHosts.length > 0 ? options.allowedHosts
|
|
414
427
|
: current?.allowed_hosts?.filter((host) => host !== current.listen.address) ?? [],
|
|
415
428
|
reuseCurrentPort: options.port === undefined,
|
|
429
|
+
activeBinding,
|
|
416
430
|
});
|
|
417
431
|
try {
|
|
418
432
|
if (configured.hub !== null) await ensureDashboard({ env });
|
package/src/bridge-config.mjs
CHANGED
|
@@ -332,6 +332,7 @@ export async function configureBridge({
|
|
|
332
332
|
createCandidateServer = () => createServer(),
|
|
333
333
|
choosePort = () => randomInt(BRIDGE_PORT_MIN, BRIDGE_PORT_MAX + 1),
|
|
334
334
|
reuseCurrentPort = true,
|
|
335
|
+
activeBinding = null,
|
|
335
336
|
} = {}) {
|
|
336
337
|
const normalized = validateMutation({ address, port, upstream, hub, allowedHosts });
|
|
337
338
|
const refs = bridgeConfigPaths(env);
|
|
@@ -341,7 +342,11 @@ export async function configureBridge({
|
|
|
341
342
|
const selectedPort = port ?? (reuseCurrentPort && current?.enabled && current.listen.address === address
|
|
342
343
|
? current.listen.port : null);
|
|
343
344
|
const bindingUnchanged = current?.enabled === true && current.listen.address === address
|
|
344
|
-
&& current.listen.port === selectedPort
|
|
345
|
+
&& current.listen.port === selectedPort
|
|
346
|
+
// activeBindingはbridge-cliがinstance tokenで本人確認した待受だけを渡す。
|
|
347
|
+
// DHCP追従済みの自分のsocketを再予約すると、無関係なport競合と誤認する。
|
|
348
|
+
|| current?.enabled === true && activeBinding?.address === address
|
|
349
|
+
&& activeBinding.port === selectedPort;
|
|
345
350
|
let reservation = null;
|
|
346
351
|
try {
|
|
347
352
|
if (!bindingUnchanged) {
|
package/src/bridge-daemon.mjs
CHANGED
|
@@ -3,12 +3,14 @@ import { randomBytes } from 'node:crypto';
|
|
|
3
3
|
import { constants as fsConstants } from 'node:fs';
|
|
4
4
|
import { chmod, lstat, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { isIP } from 'node:net';
|
|
6
|
+
import { networkInterfaces } from 'node:os';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { parseTree } from 'jsonc-parser';
|
|
8
9
|
|
|
9
10
|
import {
|
|
10
11
|
BRIDGE_PORT_MAX, BRIDGE_PORT_MIN, BridgeConfigError, bridgeConfigPaths, readBridgeConfig,
|
|
11
12
|
} from './bridge-config.mjs';
|
|
13
|
+
import { resolveBridgeListenAddress } from './bridge-address.mjs';
|
|
12
14
|
import packageJson from '../package.json' with { type: 'json' };
|
|
13
15
|
|
|
14
16
|
const DESCRIPTOR_SCHEMA = 'lattice.bridge_daemon.v1';
|
|
@@ -202,7 +204,11 @@ export async function clearBridgeStopControl({ env = process.env } = {}) {
|
|
|
202
204
|
export async function requestBridgeDaemonStop({ env = process.env, listen = null } = {}) {
|
|
203
205
|
let descriptor = null;
|
|
204
206
|
try { descriptor = await readBridgeDaemonDescriptor({ env }); } catch {}
|
|
205
|
-
|
|
207
|
+
let config = null;
|
|
208
|
+
try { config = await readBridgeConfig({ env }); } catch (error) {
|
|
209
|
+
if (!['BRIDGE_CONFIG_INVALID', 'BRIDGE_CONFIG_MODE_INVALID'].includes(error?.code)) throw error;
|
|
210
|
+
}
|
|
211
|
+
if (descriptor !== null && await attestKnownBinding(descriptor, config) === null
|
|
206
212
|
&& !await bridgeEndpointAvailable({ address: descriptor.address, port: descriptor.port })) {
|
|
207
213
|
return { state: 'not_running', nonce: null };
|
|
208
214
|
}
|
|
@@ -243,15 +249,22 @@ export async function requestBridgeDaemonStop({ env = process.env, listen = null
|
|
|
243
249
|
throw new BridgeConfigError('BRIDGE_DAEMON_STOP_FAILED', 'bridge daemon stop receipt timed out');
|
|
244
250
|
}
|
|
245
251
|
|
|
246
|
-
export async function writeBridgeDaemonDescriptor({ config,
|
|
247
|
-
|
|
252
|
+
export async function writeBridgeDaemonDescriptor({ config, binding = config?.listen, identity = null,
|
|
253
|
+
env = process.env } = {}) {
|
|
254
|
+
const instanceToken = identity?.instance_token ?? env.LATTICE_BRIDGE_INSTANCE_TOKEN;
|
|
248
255
|
if (typeof instanceToken !== 'string' || !/^[0-9a-f]{64}$/u.test(instanceToken)) {
|
|
249
256
|
throw new BridgeConfigError('BRIDGE_INSTANCE_TOKEN_INVALID', 'bridge instance token is invalid');
|
|
250
257
|
}
|
|
251
|
-
|
|
252
|
-
|
|
258
|
+
if (isIP(binding?.address) === 0 || !Number.isSafeInteger(binding?.port)
|
|
259
|
+
|| binding.port < BRIDGE_PORT_MIN || binding.port > BRIDGE_PORT_MAX
|
|
260
|
+
|| !Number.isSafeInteger(identity?.pid ?? process.pid) || (identity?.pid ?? process.pid) <= 0
|
|
261
|
+
|| identity !== null && !isIsoTimestamp(identity.started_at)) {
|
|
262
|
+
throw new BridgeConfigError('BRIDGE_DAEMON_DESCRIPTOR_INVALID', 'bridge daemon descriptor identity is invalid');
|
|
263
|
+
}
|
|
264
|
+
const descriptor = { schema: DESCRIPTOR_SCHEMA, pid: identity?.pid ?? process.pid,
|
|
265
|
+
address: binding.address, port: binding.port,
|
|
253
266
|
config_updated_at: config.updated_at, instance_token: instanceToken,
|
|
254
|
-
started_at: new Date().toISOString() };
|
|
267
|
+
started_at: identity?.started_at ?? new Date().toISOString() };
|
|
255
268
|
await atomicDescriptor(bridgeDaemonDescriptorPath(env), descriptor);
|
|
256
269
|
await atomicDescriptor(bridgeDaemonActiveMarkerPath(env), {
|
|
257
270
|
schema: ACTIVE_MARKER_SCHEMA, address: descriptor.address, port: descriptor.port,
|
|
@@ -301,10 +314,10 @@ export async function waitForBridgeSocketClose({ listen, timeoutMs = STOP_TIMEOU
|
|
|
301
314
|
return !await bridgeEndpointAvailable(listen);
|
|
302
315
|
}
|
|
303
316
|
|
|
304
|
-
async function attest(descriptor) {
|
|
317
|
+
async function attest(descriptor, { address = descriptor?.address } = {}) {
|
|
305
318
|
if (descriptor === null) return null;
|
|
306
319
|
try {
|
|
307
|
-
const response = await fetch(`http://${healthHost(
|
|
320
|
+
const response = await fetch(`http://${healthHost(address)}:${descriptor.port}/__lattice/bridge-health`, {
|
|
308
321
|
headers: { 'x-lattice-bridge-instance-token': descriptor.instance_token },
|
|
309
322
|
signal: AbortSignal.timeout(400),
|
|
310
323
|
});
|
|
@@ -314,6 +327,29 @@ async function attest(descriptor) {
|
|
|
314
327
|
} catch { return null; }
|
|
315
328
|
}
|
|
316
329
|
|
|
330
|
+
function currentBinding(config, interfaces = networkInterfaces()) {
|
|
331
|
+
if (config === null || config.enabled !== true) return null;
|
|
332
|
+
const address = resolveBridgeListenAddress({ configured: config.listen.address, interfaces }).effective;
|
|
333
|
+
return address === null ? null : { address, port: config.listen.port };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function attestCurrentBinding(descriptor, config, { interfaces = networkInterfaces() } = {}) {
|
|
337
|
+
const binding = currentBinding(config, interfaces);
|
|
338
|
+
if (descriptor === null || binding === null || descriptor.port !== binding.port) return null;
|
|
339
|
+
const body = await attest(descriptor, { address: binding.address });
|
|
340
|
+
return body === null ? null : { body, binding };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 設定変更から次のreconcileまでの間は、旧socketだけが本人確認に応答することがある。
|
|
344
|
+
// 現在の待受に続いてdescriptorの既知の宛先を確認し、instance tokenによる本人確認を保つ。
|
|
345
|
+
async function attestKnownBinding(descriptor, config, options = {}) {
|
|
346
|
+
const current = await attestCurrentBinding(descriptor, config, options);
|
|
347
|
+
if (current !== null) return current;
|
|
348
|
+
const body = await attest(descriptor);
|
|
349
|
+
return body === null ? null : { body,
|
|
350
|
+
binding: { address: descriptor.address, port: descriptor.port } };
|
|
351
|
+
}
|
|
352
|
+
|
|
317
353
|
const UNIDENTIFIED_RUNTIME = Object.freeze({ pid: null, version: null, node_path: null,
|
|
318
354
|
node_version: null, bridge_path: null, last_heartbeat: null });
|
|
319
355
|
|
|
@@ -326,14 +362,22 @@ const UNIDENTIFIED_RUNTIME = Object.freeze({ pid: null, version: null, node_path
|
|
|
326
362
|
* thrown: this feeds `lattice bridge status`, the command an operator reaches
|
|
327
363
|
* for precisely when something is already broken.
|
|
328
364
|
*/
|
|
329
|
-
export async function readBridgeRuntimeIdentity({ env = process.env } = {}) {
|
|
365
|
+
export async function readBridgeRuntimeIdentity({ env = process.env, interfaces = networkInterfaces() } = {}) {
|
|
330
366
|
let descriptor;
|
|
331
367
|
try { descriptor = await readBridgeDaemonDescriptor({ env }); } catch (error) {
|
|
332
368
|
if (error?.code !== 'BRIDGE_DAEMON_DESCRIPTOR_INVALID') throw error;
|
|
333
369
|
return { ...UNIDENTIFIED_RUNTIME, state: 'descriptor_invalid' };
|
|
334
370
|
}
|
|
335
371
|
if (descriptor === null) return { ...UNIDENTIFIED_RUNTIME, state: 'not_running' };
|
|
336
|
-
|
|
372
|
+
// 旧版daemonはDHCP移動後も設定IPをdescriptorへ残す。
|
|
373
|
+
// serverと共通の同一subnet条件で現在の待受を解決してから本人確認する。
|
|
374
|
+
let config = null;
|
|
375
|
+
try {
|
|
376
|
+
config = await readBridgeConfig({ env });
|
|
377
|
+
} catch (error) {
|
|
378
|
+
if (!['BRIDGE_CONFIG_INVALID', 'BRIDGE_CONFIG_MODE_INVALID'].includes(error?.code)) throw error;
|
|
379
|
+
}
|
|
380
|
+
const body = (await attestKnownBinding(descriptor, config, { interfaces }))?.body ?? null;
|
|
337
381
|
if (body === null) return { ...UNIDENTIFIED_RUNTIME, state: 'unattested', pid: descriptor.pid };
|
|
338
382
|
const text = (value) => (typeof value === 'string' ? value : null);
|
|
339
383
|
return { state: 'running', pid: descriptor.pid, version: text(body.version),
|
|
@@ -344,31 +388,42 @@ export async function readBridgeRuntimeIdentity({ env = process.env } = {}) {
|
|
|
344
388
|
last_heartbeat: body.last_heartbeat ?? null };
|
|
345
389
|
}
|
|
346
390
|
|
|
347
|
-
async function healthy(descriptor, config) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const body = await attest(descriptor);
|
|
351
|
-
return body !== null && body.updated_at === config.updated_at;
|
|
391
|
+
async function healthy(descriptor, config, options = {}) {
|
|
392
|
+
const attested = await attestCurrentBinding(descriptor, config, options);
|
|
393
|
+
return attested !== null && attested.body.updated_at === config.updated_at ? attested : null;
|
|
352
394
|
}
|
|
353
395
|
|
|
354
|
-
|
|
396
|
+
async function preserveHealthyDescriptor(descriptor, config, options) {
|
|
397
|
+
const attested = await healthy(descriptor, config, options);
|
|
398
|
+
if (attested === null) return null;
|
|
399
|
+
if (descriptor.address === attested.binding.address && descriptor.port === attested.binding.port) return descriptor;
|
|
400
|
+
return writeBridgeDaemonDescriptor({ config, binding: attested.binding, identity: descriptor, env: options.env });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function ensureBridgeDaemon({ env = process.env, interfaces = networkInterfaces(),
|
|
404
|
+
spawnDaemon = spawn } = {}) {
|
|
355
405
|
const config = await readBridgeConfig({ env });
|
|
356
406
|
if (config === null || !config.enabled) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
|
|
357
407
|
const previous = await readBridgeDaemonDescriptor({ env });
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
408
|
+
const options = { env, interfaces };
|
|
409
|
+
const alreadyHealthy = await preserveHealthyDescriptor(previous, config, options);
|
|
410
|
+
if (alreadyHealthy !== null) return alreadyHealthy;
|
|
411
|
+
if (previous !== null && previous.port === config.listen.port
|
|
412
|
+
&& await attestKnownBinding(previous, config, options) !== null) {
|
|
361
413
|
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
362
414
|
while (Date.now() < deadline) {
|
|
363
415
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
364
416
|
const reconciled = await readBridgeDaemonDescriptor({ env });
|
|
365
|
-
if (reconciled?.pid === previous.pid
|
|
417
|
+
if (reconciled?.pid === previous.pid) {
|
|
418
|
+
const healthyDescriptor = await preserveHealthyDescriptor(reconciled, config, options);
|
|
419
|
+
if (healthyDescriptor !== null) return healthyDescriptor;
|
|
420
|
+
}
|
|
366
421
|
}
|
|
367
422
|
throw new BridgeConfigError('BRIDGE_DAEMON_UNAVAILABLE',
|
|
368
423
|
'existing bridge daemon did not reconcile the updated config');
|
|
369
424
|
}
|
|
370
425
|
const instanceToken = randomBytes(32).toString('hex');
|
|
371
|
-
const child =
|
|
426
|
+
const child = spawnDaemon(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-bridge.mjs')], {
|
|
372
427
|
detached: true, stdio: 'ignore', env: { ...env, LATTICE_BRIDGE_INSTANCE_TOKEN: instanceToken },
|
|
373
428
|
});
|
|
374
429
|
child.unref();
|
|
@@ -376,22 +431,27 @@ export async function ensureBridgeDaemon({ env = process.env } = {}) {
|
|
|
376
431
|
while (Date.now() < deadline) {
|
|
377
432
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
378
433
|
const descriptor = await readBridgeDaemonDescriptor({ env });
|
|
379
|
-
|
|
434
|
+
const healthyDescriptor = await preserveHealthyDescriptor(descriptor, config, options);
|
|
435
|
+
if (healthyDescriptor !== null) {
|
|
380
436
|
if (previous !== null && previous.pid !== descriptor.pid) {
|
|
381
|
-
if (await
|
|
437
|
+
if (await attestCurrentBinding(previous, config, options) !== null) {
|
|
382
438
|
try { process.kill(previous.pid, 'SIGTERM'); } catch {}
|
|
383
439
|
}
|
|
384
440
|
}
|
|
385
|
-
return
|
|
441
|
+
return healthyDescriptor;
|
|
386
442
|
}
|
|
387
443
|
}
|
|
388
444
|
throw new BridgeConfigError('BRIDGE_DAEMON_UNAVAILABLE', 'bridge daemon did not bind and become healthy');
|
|
389
445
|
}
|
|
390
446
|
|
|
391
|
-
export async function stopBridgeDaemon({ env = process.env } = {}) {
|
|
447
|
+
export async function stopBridgeDaemon({ env = process.env, interfaces = networkInterfaces() } = {}) {
|
|
392
448
|
const descriptor = await readBridgeDaemonDescriptor({ env });
|
|
393
449
|
if (descriptor === null) return null;
|
|
394
|
-
|
|
450
|
+
let config = null;
|
|
451
|
+
try { config = await readBridgeConfig({ env }); } catch (error) {
|
|
452
|
+
if (!['BRIDGE_CONFIG_INVALID', 'BRIDGE_CONFIG_MODE_INVALID'].includes(error?.code)) throw error;
|
|
453
|
+
}
|
|
454
|
+
if (await attestKnownBinding(descriptor, config, { interfaces }) === null) {
|
|
395
455
|
let alive = true;
|
|
396
456
|
try { process.kill(descriptor.pid, 0); } catch { alive = false; }
|
|
397
457
|
if (!alive) {
|
|
@@ -197,12 +197,12 @@ export function createBridgeHubHeartbeatController({
|
|
|
197
197
|
return result;
|
|
198
198
|
};
|
|
199
199
|
return Object.freeze({
|
|
200
|
-
async tick({ config }) {
|
|
200
|
+
async tick({ config, force = false }) {
|
|
201
201
|
if (config?.hub == null) {
|
|
202
202
|
lastSentAt = null; lastResult = null; lastResultAt = null; return null;
|
|
203
203
|
}
|
|
204
204
|
const nowMs = now();
|
|
205
|
-
if (lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
|
|
205
|
+
if (!force && lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
|
|
206
206
|
lastSentAt = nowMs;
|
|
207
207
|
// 名乗る集合は、この端末が実際に配信している集合そのものを使う。登録簿から別途
|
|
208
208
|
// 数え直すと、配信している側と名乗る側が別々の基準を持ち、必ずずれる(ADR 0165)。
|
|
@@ -217,12 +217,11 @@ function healthHost(address) {
|
|
|
217
217
|
return address.includes(':') ? `[${address}]` : address;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
async function
|
|
220
|
+
export async function waitForBridgeLaunchAgentReady({ config, instanceToken, env, timeoutMs = START_TIMEOUT_MS }) {
|
|
221
221
|
const deadline = Date.now() + timeoutMs;
|
|
222
222
|
while (Date.now() < deadline) {
|
|
223
223
|
const descriptor = await readBridgeDaemonDescriptor({ env });
|
|
224
|
-
if (descriptor?.
|
|
225
|
-
&& descriptor?.config_updated_at === config.updated_at) {
|
|
224
|
+
if (descriptor?.port === config.listen.port && descriptor?.config_updated_at === config.updated_at) {
|
|
226
225
|
try {
|
|
227
226
|
const response = await fetch(
|
|
228
227
|
`http://${healthHost(descriptor.address)}:${descriptor.port}/__lattice/bridge-health`, {
|
|
@@ -231,6 +230,7 @@ async function defaultWaitReady({ config, instanceToken, env, timeoutMs = START_
|
|
|
231
230
|
});
|
|
232
231
|
const body = response.status === 200 ? await response.json() : null;
|
|
233
232
|
if (body?.schema === 'lattice.bridge_health.v1' && body.pid === descriptor.pid
|
|
233
|
+
&& body.address === descriptor.address && body.port === descriptor.port
|
|
234
234
|
&& body.updated_at === config.updated_at) return descriptor;
|
|
235
235
|
} catch {}
|
|
236
236
|
}
|
|
@@ -321,7 +321,7 @@ async function bootoutIfLoaded({ runner, uid }) {
|
|
|
321
321
|
export async function installBridgeLaunchAgent({ config, env = process.env,
|
|
322
322
|
runner = defaultLaunchctlRunner, uid = userId(), nodePath = process.execPath,
|
|
323
323
|
bridgePath = DEFAULT_BRIDGE_PATH,
|
|
324
|
-
waitReady =
|
|
324
|
+
waitReady = waitForBridgeLaunchAgentReady, waitStopped = defaultWaitStopped,
|
|
325
325
|
stableNode = stableNodePath, previousListen = null } = {}) {
|
|
326
326
|
if (config?.enabled !== true) throw fail('BRIDGE_DISABLED', 'bridge is disabled');
|
|
327
327
|
const refs = bridgeLaunchAgentPaths(env);
|
|
@@ -360,7 +360,7 @@ export async function disableBridgeLaunchAgent({ snapshot, listen, env = process
|
|
|
360
360
|
|
|
361
361
|
export async function restoreBridgeLaunchAgent({ snapshot, listen = null, env = process.env,
|
|
362
362
|
runner = defaultLaunchctlRunner, uid = userId(), waitStopped = defaultWaitStopped,
|
|
363
|
-
config = undefined, waitReady =
|
|
363
|
+
config = undefined, waitReady = waitForBridgeLaunchAgentReady } = {}) {
|
|
364
364
|
if (!snapshot || typeof snapshot.installed !== 'boolean' || typeof snapshot.loaded !== 'boolean'
|
|
365
365
|
|| (snapshot.installed && typeof snapshot.content !== 'string')) {
|
|
366
366
|
throw new TypeError('bridge LaunchAgent snapshot required');
|
package/src/bridge-server.mjs
CHANGED
|
@@ -238,7 +238,8 @@ export async function startBridgeServer({
|
|
|
238
238
|
// availability probe.
|
|
239
239
|
response.end(`${JSON.stringify(attested
|
|
240
240
|
? { schema: 'lattice.bridge_health.v1', pid: process.pid,
|
|
241
|
-
|
|
241
|
+
// 設定IPは公開networkの意図として保ち、healthは実際の待受を返す。
|
|
242
|
+
address: listenAddress, port: actualPort,
|
|
242
243
|
updated_at: currentConfig.updated_at ?? null,
|
|
243
244
|
version: packageJson.version, node_path: process.execPath,
|
|
244
245
|
node_version: process.version, bridge_path: process.argv[1] ?? null,
|
|
@@ -198,12 +198,11 @@ function healthHost(address) {
|
|
|
198
198
|
return address.includes(':') ? `[${address}]` : address;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
async function
|
|
201
|
+
export async function waitForBridgeStartupReady({ config, instanceToken, env, timeoutMs = START_TIMEOUT_MS }) {
|
|
202
202
|
const deadline = Date.now() + timeoutMs;
|
|
203
203
|
while (Date.now() < deadline) {
|
|
204
204
|
const descriptor = await readBridgeDaemonDescriptor({ env });
|
|
205
|
-
if (descriptor?.
|
|
206
|
-
&& descriptor?.config_updated_at === config.updated_at) {
|
|
205
|
+
if (descriptor?.port === config.listen.port && descriptor?.config_updated_at === config.updated_at) {
|
|
207
206
|
try {
|
|
208
207
|
const response = await fetch(
|
|
209
208
|
`http://${healthHost(descriptor.address)}:${descriptor.port}/__lattice/bridge-health`, {
|
|
@@ -212,6 +211,7 @@ async function defaultWaitReady({ config, instanceToken, env, timeoutMs = START_
|
|
|
212
211
|
});
|
|
213
212
|
const body = response.status === 200 ? await response.json() : null;
|
|
214
213
|
if (body?.schema === 'lattice.bridge_health.v1' && body.pid === descriptor.pid
|
|
214
|
+
&& body.address === descriptor.address && body.port === descriptor.port
|
|
215
215
|
&& body.updated_at === config.updated_at) return descriptor;
|
|
216
216
|
} catch {}
|
|
217
217
|
}
|
|
@@ -300,7 +300,7 @@ async function stopRunning({ env, listen, runner, waitStopped }) {
|
|
|
300
300
|
export async function installBridgeStartupFolder({ config, env = process.env,
|
|
301
301
|
runner = defaultStartupRunner, nodePath = process.execPath,
|
|
302
302
|
bridgePath = DEFAULT_BRIDGE_PATH, supervisorPath = DEFAULT_SUPERVISOR_PATH,
|
|
303
|
-
waitReady =
|
|
303
|
+
waitReady = waitForBridgeStartupReady, waitStopped = defaultWaitStopped,
|
|
304
304
|
stableNode = stableNodePath, previousListen = null } = {}) {
|
|
305
305
|
if (config?.enabled !== true) throw fail('BRIDGE_DISABLED', 'bridge is disabled');
|
|
306
306
|
const refs = bridgeStartupFolderPaths(env);
|
|
@@ -354,7 +354,7 @@ export async function disableBridgeStartupFolder({ snapshot, listen, env = proce
|
|
|
354
354
|
|
|
355
355
|
export async function restoreBridgeStartupFolder({ snapshot, listen = null, env = process.env,
|
|
356
356
|
runner = defaultStartupRunner, waitStopped = defaultWaitStopped,
|
|
357
|
-
config = undefined, waitReady =
|
|
357
|
+
config = undefined, waitReady = waitForBridgeStartupReady } = {}) {
|
|
358
358
|
if (!snapshot || typeof snapshot.installed !== 'boolean'
|
|
359
359
|
|| (snapshot.installed
|
|
360
360
|
&& (typeof snapshot.launcherContent !== 'string' || typeof snapshot.descriptorContent !== 'string'))) {
|
|
@@ -25,6 +25,25 @@ import {
|
|
|
25
25
|
readTodoIndependenceArtifact, readTodoStore, readTodoWitnessSet,
|
|
26
26
|
} from './todo-store.mjs';
|
|
27
27
|
|
|
28
|
+
export function ownScopeGrowth(taskId, finding) {
|
|
29
|
+
return finding?.kind === 'undeclared_write'
|
|
30
|
+
&& Array.isArray(finding.todo_ids)
|
|
31
|
+
&& finding.todo_ids.length === 1
|
|
32
|
+
&& finding.todo_ids[0] === taskId
|
|
33
|
+
&& typeof finding.path === 'string'
|
|
34
|
+
&& finding.path.length > 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function acceptFindings(taskId, findings) {
|
|
38
|
+
const blocking = [];
|
|
39
|
+
const added = [];
|
|
40
|
+
for (const finding of findings) {
|
|
41
|
+
if (ownScopeGrowth(taskId, finding)) added.push(finding.path);
|
|
42
|
+
else blocking.push(finding);
|
|
43
|
+
}
|
|
44
|
+
return { blocking, added_paths: [...new Set(added)].sort() };
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
export const PULL_RUN_META_SCHEMA = 'lattice.pull_run_meta.v1';
|
|
29
48
|
const PULL_EVENT_SCHEMA = 'lattice.pull_run_event.v1';
|
|
30
49
|
const PULL_EVENTS_FILE = 'pull-events.json';
|
|
@@ -1219,15 +1238,16 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
|
|
|
1219
1238
|
const findings = [...direct, ...independent].filter((finding, index, all) => (
|
|
1220
1239
|
index === all.findIndex((candidate) => digestArtifact(candidate) === digestArtifact(finding))
|
|
1221
1240
|
));
|
|
1222
|
-
|
|
1223
|
-
|
|
1241
|
+
const { blocking, added_paths: addedPaths } = acceptFindings(taskId, findings);
|
|
1242
|
+
if (blocking.length > 0) {
|
|
1243
|
+
const affected = new Set(blocking.flatMap((finding) => finding.todo_ids));
|
|
1224
1244
|
for (const affectedTaskId of [...affected].sort()) {
|
|
1225
1245
|
const affectedIntake = project(current.events, current.meta).intakes
|
|
1226
1246
|
.find((entry) => entry.task_id === affectedTaskId);
|
|
1227
1247
|
if (!affectedIntake || affectedIntake.accepted !== null) continue;
|
|
1228
1248
|
const intervention = { state: 'hold', reason: 'runtime_conflict',
|
|
1229
1249
|
next_action: 'inspect_findings_and_resolve_or_split', lease_state: 'revoked',
|
|
1230
|
-
detail: { findings:
|
|
1250
|
+
detail: { findings: blocking.filter((finding) => finding.todo_ids.includes(affectedTaskId)) } };
|
|
1231
1251
|
current = await changeIntervention(runDir, current, affectedTaskId, intervention);
|
|
1232
1252
|
const stoppedIntake = project(current.events, current.meta).intakes
|
|
1233
1253
|
.find((entry) => entry.task_id === affectedTaskId);
|
|
@@ -1240,7 +1260,7 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
|
|
|
1240
1260
|
}
|
|
1241
1261
|
}
|
|
1242
1262
|
}
|
|
1243
|
-
fail('RUNTIME_CONFLICT_HOLD', 'observed diffがruntime conflictを生成した', { findings });
|
|
1263
|
+
fail('RUNTIME_CONFLICT_HOLD', 'observed diffがruntime conflictを生成した', { findings: blocking });
|
|
1244
1264
|
}
|
|
1245
1265
|
if (intake.intervention.state === 'hold' && intake.intervention.reason === 'runtime_conflict') {
|
|
1246
1266
|
const released = { state: 'none', reason: null, next_action: null,
|
|
@@ -1261,7 +1281,8 @@ export async function acceptPullTask({ repoRoot, runDir, taskId, environment = p
|
|
|
1261
1281
|
events: current.events, meta: current.meta, kind: 'task_accepted', taskId,
|
|
1262
1282
|
payload: { done_event_digest: done.event_digest,
|
|
1263
1283
|
checkpoint_digest: checkpoint.checkpoint_digest, checkpoint,
|
|
1264
|
-
head_sha: checkpoint.diff.head_sha
|
|
1284
|
+
head_sha: checkpoint.diff.head_sha,
|
|
1285
|
+
...(addedPaths.length > 0 ? { added_paths: addedPaths } : {}) },
|
|
1265
1286
|
}));
|
|
1266
1287
|
|
|
1267
1288
|
// 先着taskがacceptedになった後、planning conflictだけで待っていた後着を再投影する。
|