@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 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環境変数を渡せます。未設定ならhost/`session`/USERから
390
- defaultします。渡した値がidentifierとして不正なら`ACTOR_UNRESOLVED`です。
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(({ observeRuntimeError, runtimeErrorSafeContext }) => {
208
- observeRuntimeError('LATTICE.CLI_INTERNAL_FAILED', {
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.76.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 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
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's own display name, shown for every project it registers
89
- * (registry entries are per-project but `display_name` is terminal-wide —
90
- * ADR 0162 Decision 2). Hostname, not a project name: the active project set
91
- * changes heartbeat to heartbeat but the terminal's identity does not.
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
- return hostname().slice(0, 128) || 'terminal';
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'].includes(key))
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, and how project ids render.
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.display_name,
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
  }));
@@ -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
+ }
@@ -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: typeof error?.code === 'string' && CAUSE_CODE.test(error.code) ? error.code : 'none',
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 !== definition.severity || record.fingerprint !== fingerprintOf(record.error_code, safeContext)
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: definition.severity, fingerprint: key,
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
- : sanitizeActorIdentifier(hostname(), 'host'),
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
- if (error instanceof TypeError) {
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,