@quolu/lattice 0.76.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +5 -2
- package/bin/lattice.mjs +3 -4
- package/package.json +1 -1
- package/src/bridge-hub-heartbeat.mjs +11 -14
- package/src/bridge-hub-server.mjs +30 -4
- package/src/cli-command-kind.mjs +6 -0
- package/src/cli-failure-class.mjs +34 -0
- package/src/runtime-errors.mjs +34 -3
- package/src/terminal-identity.mjs +93 -0
- package/src/todo-cli.mjs +15 -5
package/README.ja.md
CHANGED
|
@@ -386,8 +386,11 @@ pidへは送りません(pid再利用で無関係のprocessを止めうるた
|
|
|
386
386
|
[ADR 0157](https://github.com/kitepon/Lattice/blob/main/docs/adr/0157-dashboard-daemons-are-discoverable-by-record.md)が正です。
|
|
387
387
|
状態を書き込む`start / block / unblock / done / evidence promote / reopen / note / independence mode /
|
|
388
388
|
revise / revise-phase / revise-set / phase review|accept|reject|reopen|close-unaudited|baseline /
|
|
389
|
-
dashboard adopt`では、監査actorとして次の3
|
|
390
|
-
|
|
389
|
+
dashboard adopt`では、監査actorとして次の3環境変数を渡せます。未設定なら、hostは端末ごとのlabel
|
|
390
|
+
(`terminal-`と16進12桁)、sessionは`session`、agentはUSERになります。hostのlabelは端末の乱数の識別子
|
|
391
|
+
(`~/.lattice/bridge-hub-terminal.json`)から導き、OSのhost名はjournalへ書きません
|
|
392
|
+
([ADR 0197](https://github.com/kitepon/Lattice/blob/main/docs/adr/0197-terminal-label-instead-of-os-hostname.md))。
|
|
393
|
+
渡した値がidentifierとして不正なら`ACTOR_UNRESOLVED`です。
|
|
391
394
|
|
|
392
395
|
```bash
|
|
393
396
|
export LATTICE_TODO_ACTOR_HOST=<host-id>
|
package/bin/lattice.mjs
CHANGED
|
@@ -203,11 +203,10 @@ async function scheduleRuntimeErrorReport() {
|
|
|
203
203
|
|
|
204
204
|
// typed契約の外へ漏れた例外を、opt-inのruntime error記録へ残す。どの面で・どの種類の例外で
|
|
205
205
|
// 落ちたかを固定語彙の分類(safe_context)で添える——error_codeだけでは原因を追えなかった。
|
|
206
|
+
// 取消は記録せず、通信の失敗が漏れたものは内部故障と別の記録になる(ADR 0196)。
|
|
206
207
|
function observeInternalFailure(error, commandKind) {
|
|
207
|
-
pendingObservation = import('../src/runtime-errors.mjs').then(({
|
|
208
|
-
|
|
209
|
-
version: packageJson.version, safeContext: runtimeErrorSafeContext({ commandKind, error }),
|
|
210
|
-
});
|
|
208
|
+
pendingObservation = import('../src/runtime-errors.mjs').then(({ observeEscapedCliFailure }) => {
|
|
209
|
+
observeEscapedCliFailure({ error, commandKind, version: packageJson.version });
|
|
211
210
|
}).catch(() => {});
|
|
212
211
|
}
|
|
213
212
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
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",
|
|
@@ -16,22 +16,21 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { randomBytes } from 'node:crypto';
|
|
19
|
-
import { hostname } from 'node:os';
|
|
20
19
|
import { link, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
21
20
|
import path from 'node:path';
|
|
22
21
|
|
|
23
22
|
import { bridgeConfigPaths } from './bridge-config.mjs';
|
|
24
23
|
import { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS, validateBridgeHubRegistrationRequest } from './bridge-hub-protocol.mjs';
|
|
24
|
+
import { TERMINAL_IDENTIFIER, TERMINAL_IDENTITY_SCHEMA, terminalIdentityPath, terminalPublicLabel } from './terminal-identity.mjs';
|
|
25
25
|
import { readServedTodoDashboardProjectIds } from './todo-dashboard-registry.mjs';
|
|
26
26
|
|
|
27
27
|
export { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS };
|
|
28
28
|
|
|
29
|
-
const TERMINAL_IDENTITY_SCHEMA = 'lattice.bridge_hub_terminal_identity.v1';
|
|
30
29
|
const HEARTBEAT_RESULT_SCHEMA = 'lattice.bridge_hub_heartbeat_result.v1';
|
|
31
30
|
const REGISTRATION_REQUEST_SCHEMA = 'lattice.bridge_hub_registration_request.v1';
|
|
32
31
|
const REGISTER_PATH = '__lattice/hub/register';
|
|
33
32
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
34
|
-
const IDENTIFIER =
|
|
33
|
+
const IDENTIFIER = TERMINAL_IDENTIFIER;
|
|
35
34
|
|
|
36
35
|
export class BridgeHubHeartbeatError extends Error {
|
|
37
36
|
constructor(code, message, detail = undefined, cause = undefined) {
|
|
@@ -42,10 +41,6 @@ export class BridgeHubHeartbeatError extends Error {
|
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
function terminalIdentityPath(env) {
|
|
46
|
-
return path.join(bridgeConfigPaths(env).root, 'bridge-hub-terminal.json');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
44
|
/**
|
|
50
45
|
* This terminal's stable bridge-hub identity, created once and reused across
|
|
51
46
|
* restarts. It must survive restarts: registration is a full-state
|
|
@@ -85,13 +80,15 @@ export async function readOrCreateBridgeHubTerminalId({ env = process.env } = {}
|
|
|
85
80
|
}
|
|
86
81
|
|
|
87
82
|
/**
|
|
88
|
-
* The terminal
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
83
|
+
* The name this terminal registers under (ADR 0197). It is the public label
|
|
84
|
+
* derived from the terminal id, never the OS hostname: the hub landing is
|
|
85
|
+
* served to anyone, and a hostname routinely holds its owner's name. Still
|
|
86
|
+
* terminal-wide (ADR 0162 Decision 2) — the active project set changes
|
|
87
|
+
* heartbeat to heartbeat but the terminal's identity does not.
|
|
92
88
|
*/
|
|
93
|
-
function terminalDisplayName() {
|
|
94
|
-
|
|
89
|
+
function terminalDisplayName(terminalId) {
|
|
90
|
+
// 不正なidはここで名付けず、下のrequest検証に型付きで落とさせる。
|
|
91
|
+
return typeof terminalId === 'string' && IDENTIFIER.test(terminalId) ? terminalPublicLabel(terminalId) : 'terminal';
|
|
95
92
|
}
|
|
96
93
|
|
|
97
94
|
/** Build and validate one registration/heartbeat request. Throws rather than
|
|
@@ -100,7 +97,7 @@ export function buildBridgeHubRegistrationRequest({ terminalId, port, projectIds
|
|
|
100
97
|
const request = {
|
|
101
98
|
schema: REGISTRATION_REQUEST_SCHEMA,
|
|
102
99
|
terminal_id: terminalId,
|
|
103
|
-
display_name: terminalDisplayName(),
|
|
100
|
+
display_name: terminalDisplayName(terminalId),
|
|
104
101
|
port,
|
|
105
102
|
project_ids: [...new Set(projectIds)].sort((left, right) => left.localeCompare(right, 'en')),
|
|
106
103
|
adopt: [...adopt],
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
projectBridgeHubRegistry, validateBridgeHubRegistryEntry,
|
|
37
37
|
} from './bridge-hub-protocol.mjs';
|
|
38
38
|
import { escapeHtml, renderBridgeHubLanding } from './bridge-hub-landing.mjs';
|
|
39
|
+
import { terminalPublicLabel } from './terminal-identity.mjs';
|
|
39
40
|
|
|
40
41
|
const LOOPBACK = '127.0.0.1';
|
|
41
42
|
const HUB_HTTP_ERROR_SCHEMA = 'lattice.bridge_hub_http_error.v1';
|
|
@@ -310,27 +311,43 @@ function validRegistryDocument(value) {
|
|
|
310
311
|
&& value.entries.every(validateBridgeHubRegistryEntry);
|
|
311
312
|
}
|
|
312
313
|
|
|
314
|
+
/** What a terminal is called on the public listing (ADR 0197). Never its self-reported name. */
|
|
315
|
+
function publicTerminalName(terminalId, terminalDisplayNames) {
|
|
316
|
+
const label = terminalPublicLabel(terminalId);
|
|
317
|
+
return Object.hasOwn(terminalDisplayNames, label) ? terminalDisplayNames[label] : label;
|
|
318
|
+
}
|
|
319
|
+
|
|
313
320
|
const EMPTY_VISIBILITY = Object.freeze({
|
|
314
321
|
hidden_project_ids: Object.freeze([]), hidden_terminal_ids: Object.freeze([]), display_names: Object.freeze({}),
|
|
322
|
+
terminal_display_names: Object.freeze({}),
|
|
315
323
|
});
|
|
316
324
|
|
|
317
325
|
function validVisibilityDocument(value) {
|
|
318
326
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
319
327
|
&& value.schema === HUB_PUBLIC_VISIBILITY_SCHEMA
|
|
320
328
|
&& Object.keys(value).every((key) =>
|
|
321
|
-
['schema', 'hidden_project_ids', 'hidden_terminal_ids', 'display_names']
|
|
329
|
+
['schema', 'hidden_project_ids', 'hidden_terminal_ids', 'display_names', 'terminal_display_names']
|
|
330
|
+
.includes(key))
|
|
322
331
|
&& Array.isArray(value.hidden_project_ids)
|
|
323
332
|
&& value.hidden_project_ids.every((entry) => typeof entry === 'string')
|
|
324
333
|
&& Array.isArray(value.hidden_terminal_ids)
|
|
325
334
|
&& value.hidden_terminal_ids.every((entry) => typeof entry === 'string')
|
|
326
335
|
&& value.display_names !== null && typeof value.display_names === 'object'
|
|
327
336
|
&& !Array.isArray(value.display_names)
|
|
328
|
-
&& Object.values(value.display_names).every((entry) => typeof entry === 'string')
|
|
337
|
+
&& Object.values(value.display_names).every((entry) => typeof entry === 'string')
|
|
338
|
+
// 任意(ADR 0197)。無い旧fileはそのまま読め、端末は導出labelで出る。
|
|
339
|
+
&& (value.terminal_display_names === undefined
|
|
340
|
+
|| (value.terminal_display_names !== null && typeof value.terminal_display_names === 'object'
|
|
341
|
+
&& !Array.isArray(value.terminal_display_names)
|
|
342
|
+
&& Object.values(value.terminal_display_names)
|
|
343
|
+
.every((entry) => typeof entry === 'string' && entry.length > 0 && entry === entry.trim())));
|
|
329
344
|
}
|
|
330
345
|
|
|
331
346
|
/**
|
|
332
347
|
* Read the operator's public-visibility file (ADR 0164 Decision 3): which
|
|
333
|
-
* projects/terminals stay off the public listing,
|
|
348
|
+
* projects/terminals stay off the public listing, how project ids render, and
|
|
349
|
+
* what each terminal is called in public (`terminal_display_names`, keyed by
|
|
350
|
+
* the terminal's public label — ADR 0197).
|
|
334
351
|
* Hot-read per request so edits apply without a hub restart. A missing file
|
|
335
352
|
* hides nothing; a malformed file is a typed error, never a silent republish
|
|
336
353
|
* of an internal terminal.
|
|
@@ -352,6 +369,7 @@ export async function readBridgeHubPublicVisibility({ env = process.env } = {})
|
|
|
352
369
|
hidden_project_ids: document.hidden_project_ids,
|
|
353
370
|
hidden_terminal_ids: document.hidden_terminal_ids,
|
|
354
371
|
display_names: document.display_names,
|
|
372
|
+
terminal_display_names: document.terminal_display_names ?? EMPTY_VISIBILITY.terminal_display_names,
|
|
355
373
|
};
|
|
356
374
|
}
|
|
357
375
|
|
|
@@ -478,10 +496,18 @@ export async function startBridgeHubServer({
|
|
|
478
496
|
// the codebase's existing posture of not leaking topology to public
|
|
479
497
|
// responses (bridge-server.mjs's public bridge-health strips pid/address
|
|
480
498
|
// the same way).
|
|
499
|
+
//
|
|
500
|
+
// `display_name` is the exception that proved the rule: it used to be the
|
|
501
|
+
// terminal's self-reported name, which was its OS hostname, published to
|
|
502
|
+
// anyone (ADR 0197). The public name is now the hub's to give — the
|
|
503
|
+
// operator's entry in `terminal_display_names`, else the label derived
|
|
504
|
+
// from the terminal id. What a terminal reports about itself stays in the
|
|
505
|
+
// registry, so a terminal still running an older Lattice cannot put its
|
|
506
|
+
// hostname back on the page, and neither can a stale offline entry.
|
|
481
507
|
const view = projected.map((entry) => ({
|
|
482
508
|
schema: HUB_PUBLIC_PROJECT_SCHEMA,
|
|
483
509
|
project_id: entry.project_id,
|
|
484
|
-
display_name: entry.
|
|
510
|
+
display_name: publicTerminalName(entry.terminal_id, visibility.terminal_display_names),
|
|
485
511
|
status: entry.status,
|
|
486
512
|
last_seen_at: entry.last_seen_at,
|
|
487
513
|
}));
|
package/src/cli-command-kind.mjs
CHANGED
|
@@ -12,6 +12,12 @@ const RUNTIME_SURFACES = Object.freeze({
|
|
|
12
12
|
event: Object.freeze(['verify']),
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* storeも端末の状態も書き換えないと確かめた面(ADR 0196)。通信の失敗が漏れた記録の重大度を決める時に使う
|
|
17
|
+
* ——ここに在る面は、落ちても失うものが無く、打ち直せば戻る。確かめていない面は載せない。
|
|
18
|
+
*/
|
|
19
|
+
export const READ_ONLY_COMMAND_KINDS = new Set(['run.list', 'todo.status', 'todo.verify']);
|
|
20
|
+
|
|
15
21
|
export function cliCommandKind(argv, surfaces = RUNTIME_SURFACES) {
|
|
16
22
|
if (!Array.isArray(argv)) return 'other';
|
|
17
23
|
const [surface, subcommand] = argv;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* typed契約の外へ漏れた例外を、取消・通信の失敗・それ以外に分ける(ADR 0196)。
|
|
3
|
+
*
|
|
4
|
+
* runtime error記録の入口が使う。取消は故障として記録しない。通信の失敗が漏れたものは、内部故障とは
|
|
5
|
+
* 別の記録にする——原因が通信でも、型つきの答えにできず落ちたのは製品の対処不良で、直すのは回線ではなく
|
|
6
|
+
* その面の通信失敗の受け方である。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Nodeとundiciが通信の失敗に付けるcode。端末内のportの取り合い(EADDRINUSE)と、子processとのpipeでも
|
|
10
|
+
// 起きるEPIPEは、通信の失敗に数えない。
|
|
11
|
+
const TRANSPORT_CODES = new Set([
|
|
12
|
+
'ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'EHOSTUNREACH', 'EHOSTDOWN', 'ENETUNREACH',
|
|
13
|
+
'ENETDOWN', 'ENOTFOUND', 'EAI_AGAIN', 'ENOTCONN',
|
|
14
|
+
'ERR_SOCKET_CONNECTION_TIMEOUT', 'ERR_SOCKET_CLOSED',
|
|
15
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_CLOSED',
|
|
16
|
+
]);
|
|
17
|
+
const CAUSE_DEPTH = 4;
|
|
18
|
+
|
|
19
|
+
/** 例外と、その`cause`の連なり。`fetch failed`(TypeError)は、通信のcodeを`cause`の側に持つ。 */
|
|
20
|
+
export function failureCauseChain(error) {
|
|
21
|
+
const chain = [];
|
|
22
|
+
for (let current = error; current !== null && typeof current === 'object' && chain.length < CAUSE_DEPTH;
|
|
23
|
+
current = current.cause) chain.push(current);
|
|
24
|
+
return chain;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `cancelled`(利用者や呼び出し元の取消)・`transport`(通信の失敗)・`internal`(それ以外)。 */
|
|
28
|
+
export function cliFailureClass(error) {
|
|
29
|
+
const chain = failureCauseChain(error);
|
|
30
|
+
if (chain.some((entry) => entry.name === 'AbortError' || entry.code === 'ABORT_ERR')) return 'cancelled';
|
|
31
|
+
// `AbortSignal.timeout`の時間切れは`TimeoutError`で届く。
|
|
32
|
+
if (chain.some((entry) => entry.name === 'TimeoutError' || TRANSPORT_CODES.has(entry.code))) return 'transport';
|
|
33
|
+
return 'internal';
|
|
34
|
+
}
|
package/src/runtime-errors.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
import { homedir, platform as hostPlatform, arch as hostArch } from 'node:os';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
|
|
8
|
+
import { READ_ONLY_COMMAND_KINDS } from './cli-command-kind.mjs';
|
|
9
|
+
import { cliFailureClass, failureCauseChain } from './cli-failure-class.mjs';
|
|
8
10
|
import {
|
|
9
11
|
daclIsOwnerOnly, daclIsProtected, isAclScratchName, readWindowsDacl, restrictWindowsDirToOwner, windowsSelfSid,
|
|
10
12
|
} from './windows-owner-only.mjs';
|
|
@@ -40,6 +42,9 @@ const definitions = Object.freeze({
|
|
|
40
42
|
'LATTICE.RUN_STORE_IO_FAILED': { component: 'run_store', severity: 'high', template: 'Lattice run store IO failed' },
|
|
41
43
|
'LATTICE.EVENT_CHAIN_INTEGRITY_FAILED': { component: 'event_store', severity: 'high', template: 'Lattice run event chain integrity check failed' },
|
|
42
44
|
'LATTICE.CLI_INTERNAL_FAILED': { component: 'cli', severity: 'high', template: 'Lattice CLI crashed outside the typed error contract' },
|
|
45
|
+
// 通信の失敗が型つき契約の外へ漏れた記録(ADR 0196)。直すのは回線ではなく、その面の通信失敗の受け方。
|
|
46
|
+
// 重大度は下の`severityOf`が、落ちた面で失うものがあるかで決める。
|
|
47
|
+
'LATTICE.CLI_TRANSPORT_UNHANDLED': { component: 'cli', severity: 'high', template: 'Lattice CLI let a communication failure escape the typed error contract' },
|
|
43
48
|
'LATTICE.MCP_SERVER_FAILED': { component: 'mcp', severity: 'high', template: 'Lattice MCP server failed' },
|
|
44
49
|
});
|
|
45
50
|
|
|
@@ -169,13 +174,28 @@ const validSafeContext = (value) => plain(value) && exact(value, SAFE_CONTEXT_KE
|
|
|
169
174
|
*/
|
|
170
175
|
export function runtimeErrorSafeContext({ commandKind, error } = {}) {
|
|
171
176
|
const errorKind = error?.constructor?.name;
|
|
177
|
+
// `fetch failed`のように、codeを`cause`の側に持つ例外がある。連なりの中で最初に語彙へ合うcodeを載せる。
|
|
178
|
+
const causeCode = failureCauseChain(error).map((entry) => entry.code)
|
|
179
|
+
.find((code) => typeof code === 'string' && CAUSE_CODE.test(code));
|
|
172
180
|
return {
|
|
173
181
|
command_kind: validCommandKind(commandKind) ? commandKind : 'other',
|
|
174
182
|
error_kind: ERROR_KINDS.has(errorKind) ? errorKind : 'other',
|
|
175
|
-
cause_code:
|
|
183
|
+
cause_code: causeCode ?? 'none',
|
|
176
184
|
};
|
|
177
185
|
}
|
|
178
186
|
|
|
187
|
+
/**
|
|
188
|
+
* 記録の重大度。error_codeだけでは決めない記録がある(ADR 0196): 通信の失敗が漏れた記録は、落ちた面が
|
|
189
|
+
* 何も書き換えないと確かめてある時だけ`warn`——その1回が止まっただけで、失うものが無く、打ち直せば戻る。
|
|
190
|
+
* 書き換える面と、確かめていない面は`high`のまま(結果が分からず、不整合や重複を否定できない)。
|
|
191
|
+
* 分類と面から決まる値なので、同じfingerprintの記録は必ず同じ重大度になる。
|
|
192
|
+
*/
|
|
193
|
+
function severityOf(code, safeContext = null) {
|
|
194
|
+
if (code === 'LATTICE.CLI_TRANSPORT_UNHANDLED' && safeContext !== null
|
|
195
|
+
&& READ_ONLY_COMMAND_KINDS.has(safeContext.command_kind)) return 'warn';
|
|
196
|
+
return definitions[code].severity;
|
|
197
|
+
}
|
|
198
|
+
|
|
179
199
|
/**
|
|
180
200
|
* 旧記録(safe_context無し)はerror_codeだけで決まる式のまま。新しい記録は3つの分類も含める
|
|
181
201
|
* ——原因が違えば別の記録になる。式はdotagentsのadapterが再計算して照合する契約で、
|
|
@@ -269,7 +289,7 @@ function validate(store) {
|
|
|
269
289
|
const definition = definitions[record.error_code];
|
|
270
290
|
if (!definition || record.product !== PRODUCT || !validVersion(record.product_version)
|
|
271
291
|
|| record.component !== definition.component || record.message_template !== definition.template
|
|
272
|
-
|| record.severity !==
|
|
292
|
+
|| record.severity !== severityOf(record.error_code, safeContext) || record.fingerprint !== fingerprintOf(record.error_code, safeContext)
|
|
273
293
|
|| seen.has(record.fingerprint) || !Number.isSafeInteger(record.count) || record.count < 1
|
|
274
294
|
|| !validTime(record.first_seen) || !validTime(record.last_seen)
|
|
275
295
|
|| Date.parse(record.first_seen) > Date.parse(record.last_seen)
|
|
@@ -442,7 +462,7 @@ export function recordRuntimeError(code, options = {}) {
|
|
|
442
462
|
if (store.records.length >= MAX_RECORDS) throw Error('store_overflow');
|
|
443
463
|
store.records.push({
|
|
444
464
|
product: PRODUCT, product_version: version, component: definition.component, error_code: code,
|
|
445
|
-
message_template: definition.template, severity:
|
|
465
|
+
message_template: definition.template, severity: severityOf(code, safeContext), fingerprint: key,
|
|
446
466
|
count: 1, first_seen: time, last_seen: time, state_schema_version: STATE_VERSION,
|
|
447
467
|
os, arch, status: 'open', resolved_at: null, reason_code: null, sequence,
|
|
448
468
|
safe_context: { ...safeContext },
|
|
@@ -462,6 +482,17 @@ export function observeRuntimeError(code, options = {}) {
|
|
|
462
482
|
}
|
|
463
483
|
}
|
|
464
484
|
|
|
485
|
+
/**
|
|
486
|
+
* CLIのtyped契約の外へ漏れた例外を記録する(process境界のbinが呼ぶ)。取消は故障ではないので記録しない。
|
|
487
|
+
* 通信の失敗が漏れたものは内部故障と別の記録にし、重大度を面で決める(ADR 0196)。
|
|
488
|
+
*/
|
|
489
|
+
export function observeEscapedCliFailure({ error, commandKind, ...options }) {
|
|
490
|
+
const failureClass = cliFailureClass(error);
|
|
491
|
+
if (failureClass === 'cancelled') return;
|
|
492
|
+
observeRuntimeError(failureClass === 'transport' ? 'LATTICE.CLI_TRANSPORT_UNHANDLED' : 'LATTICE.CLI_INTERNAL_FAILED',
|
|
493
|
+
{ ...options, safeContext: runtimeErrorSafeContext({ commandKind, error }) });
|
|
494
|
+
}
|
|
495
|
+
|
|
465
496
|
export function acknowledgeRuntimeErrors(cursor, options = {}) {
|
|
466
497
|
requireCursor(cursor);
|
|
467
498
|
if (!collectionEnabled(options)) return snapshot({ ...options, afterCursor: cursor });
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This terminal's identity and the label it shows to others (ADR 0197).
|
|
3
|
+
*
|
|
4
|
+
* The identity is a random id kept in the Lattice config root. It was born as
|
|
5
|
+
* the bridge-hub's routing key (`bridge-hub-heartbeat.mjs`, ADR 0162) and stays
|
|
6
|
+
* that: the id itself never leaves the terminal↔hub wire.
|
|
7
|
+
*
|
|
8
|
+
* The label is what leaves. Todo journals are committed into public
|
|
9
|
+
* repositories and the hub landing is served to anyone, so neither may carry
|
|
10
|
+
* the OS hostname — a hostname is free text the operating system chose, and
|
|
11
|
+
* it routinely holds its owner's name. The label is derived one-way from the
|
|
12
|
+
* id: stable per terminal, so records and the landing still tell terminals
|
|
13
|
+
* apart, and it says nothing about the machine or the person.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
|
+
import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { bridgeConfigPaths } from './bridge-config.mjs';
|
|
21
|
+
|
|
22
|
+
export const TERMINAL_IDENTITY_SCHEMA = 'lattice.bridge_hub_terminal_identity.v1';
|
|
23
|
+
export const TERMINAL_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
24
|
+
|
|
25
|
+
const LABEL_DOMAIN = 'lattice.terminal_public_label.v1';
|
|
26
|
+
const LABEL_HEX_LENGTH = 12;
|
|
27
|
+
|
|
28
|
+
export function terminalIdentityPath(env = process.env) {
|
|
29
|
+
return path.join(bridgeConfigPaths(env).root, 'bridge-hub-terminal.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The label a terminal is shown under in journals and on the hub landing.
|
|
34
|
+
* One-way: the routing id cannot be recovered from it, so publishing the label
|
|
35
|
+
* does not hand out the key the hub reconciles registrations by.
|
|
36
|
+
*/
|
|
37
|
+
export function terminalPublicLabel(terminalId) {
|
|
38
|
+
if (typeof terminalId !== 'string' || !TERMINAL_IDENTIFIER.test(terminalId)) {
|
|
39
|
+
throw new TypeError('terminal id is invalid');
|
|
40
|
+
}
|
|
41
|
+
const digest = createHash('sha256').update(`${LABEL_DOMAIN}\0${terminalId}`).digest('hex');
|
|
42
|
+
return `terminal-${digest.slice(0, LABEL_HEX_LENGTH)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Synchronous twin of `readOrCreateBridgeHubTerminalId`: same file, same
|
|
47
|
+
* schema, same no-overwrite publish. The todo CLI resolves its actor
|
|
48
|
+
* synchronously on every mutation, and both paths must agree on one id per
|
|
49
|
+
* terminal — a second file would give the journal and the landing two
|
|
50
|
+
* different labels for the same machine.
|
|
51
|
+
*
|
|
52
|
+
* Returns `null` when the id can be neither read nor created (read-only or
|
|
53
|
+
* missing home, invalid file). The caller decides what an unidentified
|
|
54
|
+
* terminal is called; this function never invents an id it could not persist,
|
|
55
|
+
* because an unpersisted id would change on every call.
|
|
56
|
+
*/
|
|
57
|
+
export function readOrCreateTerminalIdSync({ env = process.env } = {}) {
|
|
58
|
+
let ref;
|
|
59
|
+
try {
|
|
60
|
+
ref = terminalIdentityPath(env);
|
|
61
|
+
mkdirSync(path.dirname(ref), { recursive: true, mode: 0o700 });
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
66
|
+
try {
|
|
67
|
+
const value = JSON.parse(readFileSync(ref, 'utf8'));
|
|
68
|
+
return value?.schema === TERMINAL_IDENTITY_SCHEMA && TERMINAL_IDENTIFIER.test(value.terminal_id)
|
|
69
|
+
? value.terminal_id : null;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.code !== 'ENOENT') return null;
|
|
72
|
+
}
|
|
73
|
+
const terminalId = randomBytes(16).toString('hex');
|
|
74
|
+
const temporary = `${ref}.${terminalId}.tmp`;
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(temporary, `${JSON.stringify({ schema: TERMINAL_IDENTITY_SCHEMA, terminal_id: terminalId })}\n`,
|
|
77
|
+
{ encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
// 完成済みのbytesだけを、既存のIDを上書きせず公開する。
|
|
83
|
+
linkSync(temporary, ref);
|
|
84
|
+
return terminalId;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error?.code !== 'EEXIST') return null;
|
|
87
|
+
// 同時生成の勝者が公開した完成済みIDを読む。
|
|
88
|
+
} finally {
|
|
89
|
+
try { unlinkSync(temporary); } catch { /* 一時fileは残っても識別子を壊さない */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
package/src/todo-cli.mjs
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { cliCommandKind } from './cli-command-kind.mjs';
|
|
2
|
+
import { cliFailureClass } from './cli-failure-class.mjs';
|
|
2
3
|
import { gitSync } from './git-process.mjs';
|
|
3
4
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import {
|
|
5
|
-
hostname,
|
|
6
|
-
} from 'node:os';
|
|
7
5
|
import {
|
|
8
6
|
lstat, mkdir, readFile, realpath, writeFile,
|
|
9
7
|
} from 'node:fs/promises';
|
|
@@ -38,6 +36,7 @@ import {
|
|
|
38
36
|
ensureTodoDashboardActivity,
|
|
39
37
|
removeTodoDashboardProject,
|
|
40
38
|
} from './todo-dashboard-registry.mjs';
|
|
39
|
+
import { readOrCreateTerminalIdSync, terminalPublicLabel } from './terminal-identity.mjs';
|
|
41
40
|
import { readProjectExternalPane, resolveProjectIdentity } from './project-identity.mjs';
|
|
42
41
|
import { layoutTodoGantt } from './todo-gantt-layout.mjs';
|
|
43
42
|
import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
|
|
@@ -562,6 +561,16 @@ function sanitizeActorIdentifier(raw, fallback) {
|
|
|
562
561
|
return isTodoIdentifier(compact) ? compact : fallback;
|
|
563
562
|
}
|
|
564
563
|
|
|
564
|
+
/**
|
|
565
|
+
* 名乗りの無い操作のhost(ADR 0197)。journalは公開repositoryへcommitされるので、OSのhost名は
|
|
566
|
+
* 書かない——host名はOSが決めた自由文で、持ち主の名前を含むことがある。端末ごとの識別子から
|
|
567
|
+
* 一方向に導いたlabelを使い、端末の区別は保つ。識別子を読めず作れない端末は`host`になる。
|
|
568
|
+
*/
|
|
569
|
+
function defaultActorHost(env) {
|
|
570
|
+
const terminalId = readOrCreateTerminalIdSync({ env });
|
|
571
|
+
return terminalId === null ? 'host' : terminalPublicLabel(terminalId);
|
|
572
|
+
}
|
|
573
|
+
|
|
565
574
|
function mutationActor(env) {
|
|
566
575
|
const provided = ACTOR_ENV_KEYS.map((key) => ({ key, value: env[key] }));
|
|
567
576
|
const invalidEnvironment = provided
|
|
@@ -578,7 +587,7 @@ function mutationActor(env) {
|
|
|
578
587
|
return {
|
|
579
588
|
host: isTodoIdentifier(env.LATTICE_TODO_ACTOR_HOST)
|
|
580
589
|
? env.LATTICE_TODO_ACTOR_HOST
|
|
581
|
-
:
|
|
590
|
+
: defaultActorHost(env),
|
|
582
591
|
session: isTodoIdentifier(env.LATTICE_TODO_ACTOR_SESSION)
|
|
583
592
|
? env.LATTICE_TODO_ACTOR_SESSION
|
|
584
593
|
: 'session',
|
|
@@ -4253,7 +4262,8 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env,
|
|
|
4253
4262
|
} catch (error) {
|
|
4254
4263
|
if (error instanceof TodoStoreError || (typeof error?.code === 'string'
|
|
4255
4264
|
&& error.detail !== null && typeof error.detail === 'object')) return typedFailure(stderr, error);
|
|
4256
|
-
|
|
4265
|
+
// 漏れた`fetch failed`もTypeErrorで届く。呼び出し側の契約違反と答えず、内部故障として観測口へ渡す。
|
|
4266
|
+
if (error instanceof TypeError && cliFailureClass(error) !== 'transport') {
|
|
4257
4267
|
return typedFailure(stderr, {
|
|
4258
4268
|
code: 'CONTRACT_VIOLATION',
|
|
4259
4269
|
message: error.message,
|