@quolu/lattice 0.77.0 → 0.78.1
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/package.json +1 -1
- package/src/bridge-hub-heartbeat.mjs +11 -14
- package/src/bridge-hub-server.mjs +30 -4
- package/src/terminal-identity.mjs +98 -0
- package/src/todo-cli.mjs +12 -4
- package/src/todo-gantt-html-independence.mjs +36 -3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.1",
|
|
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
|
}));
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
/** Whether a recorded host name is a public label — the only shape a published page may show as-is (ADR 0198). */
|
|
46
|
+
export function isTerminalPublicLabel(value) {
|
|
47
|
+
return typeof value === 'string' && /^terminal-[0-9a-f]{12}$/u.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Synchronous twin of `readOrCreateBridgeHubTerminalId`: same file, same
|
|
52
|
+
* schema, same no-overwrite publish. The todo CLI resolves its actor
|
|
53
|
+
* synchronously on every mutation, and both paths must agree on one id per
|
|
54
|
+
* terminal — a second file would give the journal and the landing two
|
|
55
|
+
* different labels for the same machine.
|
|
56
|
+
*
|
|
57
|
+
* Returns `null` when the id can be neither read nor created (read-only or
|
|
58
|
+
* missing home, invalid file). The caller decides what an unidentified
|
|
59
|
+
* terminal is called; this function never invents an id it could not persist,
|
|
60
|
+
* because an unpersisted id would change on every call.
|
|
61
|
+
*/
|
|
62
|
+
export function readOrCreateTerminalIdSync({ env = process.env } = {}) {
|
|
63
|
+
let ref;
|
|
64
|
+
try {
|
|
65
|
+
ref = terminalIdentityPath(env);
|
|
66
|
+
mkdirSync(path.dirname(ref), { recursive: true, mode: 0o700 });
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
71
|
+
try {
|
|
72
|
+
const value = JSON.parse(readFileSync(ref, 'utf8'));
|
|
73
|
+
return value?.schema === TERMINAL_IDENTITY_SCHEMA && TERMINAL_IDENTIFIER.test(value.terminal_id)
|
|
74
|
+
? value.terminal_id : null;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error?.code !== 'ENOENT') return null;
|
|
77
|
+
}
|
|
78
|
+
const terminalId = randomBytes(16).toString('hex');
|
|
79
|
+
const temporary = `${ref}.${terminalId}.tmp`;
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(temporary, `${JSON.stringify({ schema: TERMINAL_IDENTITY_SCHEMA, terminal_id: terminalId })}\n`,
|
|
82
|
+
{ encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// 完成済みのbytesだけを、既存のIDを上書きせず公開する。
|
|
88
|
+
linkSync(temporary, ref);
|
|
89
|
+
return terminalId;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code !== 'EEXIST') return null;
|
|
92
|
+
// 同時生成の勝者が公開した完成済みIDを読む。
|
|
93
|
+
} finally {
|
|
94
|
+
try { unlinkSync(temporary); } catch { /* 一時fileは残っても識別子を壊さない */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
package/src/todo-cli.mjs
CHANGED
|
@@ -2,9 +2,6 @@ import { cliCommandKind } from './cli-command-kind.mjs';
|
|
|
2
2
|
import { cliFailureClass } from './cli-failure-class.mjs';
|
|
3
3
|
import { gitSync } from './git-process.mjs';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
-
import {
|
|
6
|
-
hostname,
|
|
7
|
-
} from 'node:os';
|
|
8
5
|
import {
|
|
9
6
|
lstat, mkdir, readFile, realpath, writeFile,
|
|
10
7
|
} from 'node:fs/promises';
|
|
@@ -39,6 +36,7 @@ import {
|
|
|
39
36
|
ensureTodoDashboardActivity,
|
|
40
37
|
removeTodoDashboardProject,
|
|
41
38
|
} from './todo-dashboard-registry.mjs';
|
|
39
|
+
import { readOrCreateTerminalIdSync, terminalPublicLabel } from './terminal-identity.mjs';
|
|
42
40
|
import { readProjectExternalPane, resolveProjectIdentity } from './project-identity.mjs';
|
|
43
41
|
import { layoutTodoGantt } from './todo-gantt-layout.mjs';
|
|
44
42
|
import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
|
|
@@ -563,6 +561,16 @@ function sanitizeActorIdentifier(raw, fallback) {
|
|
|
563
561
|
return isTodoIdentifier(compact) ? compact : fallback;
|
|
564
562
|
}
|
|
565
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
|
+
|
|
566
574
|
function mutationActor(env) {
|
|
567
575
|
const provided = ACTOR_ENV_KEYS.map((key) => ({ key, value: env[key] }));
|
|
568
576
|
const invalidEnvironment = provided
|
|
@@ -579,7 +587,7 @@ function mutationActor(env) {
|
|
|
579
587
|
return {
|
|
580
588
|
host: isTodoIdentifier(env.LATTICE_TODO_ACTOR_HOST)
|
|
581
589
|
? env.LATTICE_TODO_ACTOR_HOST
|
|
582
|
-
:
|
|
590
|
+
: defaultActorHost(env),
|
|
583
591
|
session: isTodoIdentifier(env.LATTICE_TODO_ACTOR_SESSION)
|
|
584
592
|
? env.LATTICE_TODO_ACTOR_SESSION
|
|
585
593
|
: 'session',
|
|
@@ -5,8 +5,39 @@ import {
|
|
|
5
5
|
renderTodoStructurePanel,
|
|
6
6
|
} from './todo-gantt-structure.mjs';
|
|
7
7
|
import { renderTodoMarkdown } from './todo-markdown-renderer.mjs';
|
|
8
|
+
import { isTerminalPublicLabel } from './terminal-identity.mjs';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* 記録に残る端末名を、ページへ出す名前へ写す(ADR 0198)。
|
|
12
|
+
*
|
|
13
|
+
* journalと作業記録の`actor.host`は、書いた時の自由文である——OSのhost名のことも、席が自分で付けた名前の
|
|
14
|
+
* こともあり、持ち主の名前を含みうる。ページは公開面へ配信されるので、そのまま出さない。出してよいのは
|
|
15
|
+
* 端末の公開label(`terminal-`と16進12桁)だけで、それ以外は同じ名前に同じ番号を振った「端末N」にする。
|
|
16
|
+
* 番号はその名前が最初に記録された時刻の順で、同時刻は名前の順。記録は追記だけなので、後から現れた名前は
|
|
17
|
+
* 後ろの番号になり、既に振った番号は動かない。記録の本体は変えない——変えるのは表示だけである。
|
|
18
|
+
*/
|
|
19
|
+
export function recordedHostDisplayNames(sections) {
|
|
20
|
+
const firstSeen = new Map();
|
|
21
|
+
for (const section of sections) {
|
|
22
|
+
for (const note of section.noteContext?.notes ?? []) {
|
|
23
|
+
const host = note.actor.host;
|
|
24
|
+
if (isTerminalPublicLabel(host)) continue;
|
|
25
|
+
const known = firstSeen.get(host);
|
|
26
|
+
if (known === undefined || note.recorded_at < known) firstSeen.set(host, note.recorded_at);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const ordered = [...firstSeen].sort(([leftHost, leftAt], [rightHost, rightAt]) => (
|
|
30
|
+
leftAt < rightAt ? -1 : leftAt > rightAt ? 1 : leftHost < rightHost ? -1 : leftHost > rightHost ? 1 : 0));
|
|
31
|
+
return new Map(ordered.map(([host], index) => [host, `端末${index + 1}`]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function recordedHostDisplayName(host, displayNames) {
|
|
35
|
+
if (isTerminalPublicLabel(host)) return host;
|
|
36
|
+
// 対応表に無い名前を素通しにしない。表を作る側の漏れは、名前でなく固定の語として現れる。
|
|
37
|
+
return displayNames.get(host) ?? '端末';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderNoteContext(context, hostDisplayNames) {
|
|
10
41
|
if (context === null) {
|
|
11
42
|
return '<section class="work-log"><h2>作業記録</h2><p class="note-warning">作業記録を読み取れません。概要の読取警告を確認してください。</p></section>';
|
|
12
43
|
}
|
|
@@ -21,7 +52,7 @@ function renderNoteContext(context) {
|
|
|
21
52
|
? `訂正済み(後継 ${note.superseded_by.slice(0, 12)}…)` : '現行';
|
|
22
53
|
// UTCのまま埋め、閲覧者の現地時刻への変換はブラウザ側へ委ねる(title属性に原文を残す)。
|
|
23
54
|
const stamp = `<time datetime="${escapeHtmlAttribute(note.recorded_at)}" title="${escapeHtmlAttribute(note.recorded_at)}" data-utc-stamp>${escapeHtmlText(note.recorded_at)}</time>`;
|
|
24
|
-
return `<article class="work-log-entry"><header>${stamp}<span>${escapeHtmlText(correction)}</span></header><div class="work-log-body">${rendered.html}</div>${filtering}<p class="work-log-origin">来歴: ${escapeHtmlText(note.origin_plan_version)}/${escapeHtmlText(note.origin_task_id)}・記録者 ${escapeHtmlText(`${note.actor.host}/${note.actor.agent}`)}</p></article>`;
|
|
55
|
+
return `<article class="work-log-entry"><header>${stamp}<span>${escapeHtmlText(correction)}</span></header><div class="work-log-body">${rendered.html}</div>${filtering}<p class="work-log-origin">来歴: ${escapeHtmlText(note.origin_plan_version)}/${escapeHtmlText(note.origin_task_id)}・記録者 ${escapeHtmlText(`${recordedHostDisplayName(note.actor.host, hostDisplayNames)}/${note.actor.agent}`)}</p></article>`;
|
|
25
56
|
}).join('');
|
|
26
57
|
const overflow = context.overflow_count === 0 ? ''
|
|
27
58
|
: `<p class="note-warning">ほか ${context.overflow_count}件は上限のため省略。全履歴: <code>${escapeHtmlText(context.full_history_command)}</code></p>`;
|
|
@@ -144,6 +175,8 @@ export function renderRightPane(
|
|
|
144
175
|
structurePresentation = null,
|
|
145
176
|
) {
|
|
146
177
|
const lookup = presentationLookup(presentation);
|
|
178
|
+
// 番号はproject全体で1つの対応表から振る。工程ごとに振ると、同じ端末が工程によって別の番号になる。
|
|
179
|
+
const hostDisplayNames = recordedHostDisplayNames(sections);
|
|
147
180
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
148
181
|
const semanticNodes = [...layout.nodes, ...(layout.hierarchy_nodes ?? [])];
|
|
149
182
|
const nodeByKey = new Map(semanticNodes.map((node) => [refKey(node.ref), node]));
|
|
@@ -212,7 +245,7 @@ export function renderRightPane(
|
|
|
212
245
|
: expandable
|
|
213
246
|
? '<p class="fold-note">完走済みのため既定の図には描いていません。左上の「完走済み」バッジを押すと、このページ内で表示できます。</p>'
|
|
214
247
|
: '<p class="fold-note">完走済みのため図には描いていません。図に出すには動的dashboardを <code>lattice todo gantt serve --port <port> --scope all</code> で起動してください。</p>';
|
|
215
|
-
const workLog = notesEnabled ? renderNoteContext(section.noteContext) : '';
|
|
248
|
+
const workLog = notesEnabled ? renderNoteContext(section.noteContext, hostDisplayNames) : '';
|
|
216
249
|
const designMemo = renderDesignMemo(section.task);
|
|
217
250
|
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}${independenceNote}${foldedNote}${designMemo}<section><h2>前提工程</h2>${renderRelationList(incoming.get(key), sectionByKey, lookup, '登録済みの前提工程はありません。', folds)}</section><section><h2>後続工程</h2>${renderRelationList(outgoing.get(key), sectionByKey, lookup, '登録済みの後続工程はありません。', folds)}</section>${workLog}<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>`;
|
|
218
251
|
}).join('');
|