@quolu/lattice 0.49.0 → 0.50.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 +12 -10
- package/README.md +5 -4
- package/bin/lattice-dashboard.mjs +5 -0
- package/package.json +1 -1
- package/src/project-identity.mjs +73 -14
- package/src/todo-cli.mjs +6 -15
- package/src/todo-gantt-live.mjs +64 -4
package/README.ja.md
CHANGED
|
@@ -51,7 +51,7 @@ planを再コンパイルします。共有面が共有でなくなるので、
|
|
|
51
51
|
`design_memo`を必ず持ちます。空欄やファイル参照だけでは登録できません。本当に何も考えていない場合だけ、
|
|
52
52
|
正確なsentinel `NO_PLAN`を本文にします。Latticeはauthoring時に次の問いを返し、空欄のまま通しません。
|
|
53
53
|
|
|
54
|
-
>
|
|
54
|
+
> あなたがこのToDoに対して、何も考えていないならば、設計メモに `NO_PLAN` と書いてください
|
|
55
55
|
|
|
56
56
|
通常の`lattice todo show`と、成功するすべての`lattice todo start`は、この初期設計メモを自動で返します。
|
|
57
57
|
作業開始後に増えた方針、棄却案、調査結果、注意、未解決事項は、別のappend-only note chainへ追記できます。
|
|
@@ -65,7 +65,7 @@ lattice todo show --plan <key> --task <id> --json
|
|
|
65
65
|
|
|
66
66
|
動的工程表は、選択したToDoの右ペインへ初期設計メモと追記noteの本文を表示します。あわせて前提工程・
|
|
67
67
|
後続工程それぞれの状態(未着手/作業中/完了/ブロック中)と、その工程の並列可否を示します。
|
|
68
|
-
note
|
|
68
|
+
note本文はHTMLを描くすべての面へ載ります。project別HTMLの生成や再生成は不要です。
|
|
69
69
|
|
|
70
70
|
### 変換の受入五条件
|
|
71
71
|
|
|
@@ -102,7 +102,7 @@ MarkItDownは別区分の第三者CLIです。
|
|
|
102
102
|
[docs/00_product-contract.md](docs/00_product-contract.md)を参照してください。
|
|
103
103
|
|
|
104
104
|
CLIの全体像は`lattice --help`、各公開namespaceの正規構文は
|
|
105
|
-
`lattice <plan|run|event|todo|sensor|factory-diagnostics|runtime-errors|bridge> --help`で確認できます。
|
|
105
|
+
`lattice <plan|run|event|todo|sensor|factory-diagnostics|runtime-errors|bridge|hooks> --help`で確認できます。
|
|
106
106
|
個別操作は`lattice <namespace> <subcommand> --help`または`lattice help <namespace> <subcommand>`で
|
|
107
107
|
正規optionをstore非依存に確認できます。
|
|
108
108
|
|
|
@@ -126,7 +126,8 @@ codex-sidecar diagnostics --project . --preset auditor --json
|
|
|
126
126
|
未初期化projectで`sensor sync`した場合は`LATTICE_SENSOR_NOT_INITIALIZED`と正規`next_action`を返します。
|
|
127
127
|
その他のsensor失敗もexit code、signal、bounded stderrをtyped detailへ残し、原因を隠しません。
|
|
128
128
|
|
|
129
|
-
Node.js 22.13
|
|
129
|
+
Node.js 22.13以上、ただし25.xを除きます(`engines: >=22.13 <25 || >=26`。Node 25.xはV8 turboshaft
|
|
130
|
+
WASM JITの不具合で同梱sensorが壊れるため、bannerを出して起動を止めます)。境界観測は配布物に同梱したLattice sensorだけを使い、PATH上の
|
|
130
131
|
廃止済みruntimeや旧cache/dataへfallbackしません。Spotterはproject単位で生成stateの所有境界を守ります。
|
|
131
132
|
|
|
132
133
|
どのrepoでも、Latticeの導入状態はdirectoryの有無を推測せず、最初に次のtyped discoveryで判定します。
|
|
@@ -136,12 +137,12 @@ lattice status --json
|
|
|
136
137
|
```
|
|
137
138
|
|
|
138
139
|
`state`は`uninitialized | ready | active_run | invalid`のいずれかです。`uninitialized`は
|
|
139
|
-
正常な未初期化状態で、`next_action`が正規の初期authoring
|
|
140
|
-
|
|
141
|
-
JSON+LF
|
|
140
|
+
正常な未初期化状態で、`next_action`が正規の初期authoring入口を返します。新規planはPhase監査と
|
|
141
|
+
ToDo schedulingを分離し、全ToDoに設計メモを持つ`lattice.plan_create_input.v4`のcanonical
|
|
142
|
+
JSON+LFを用意し、次で作成します。既存のv1〜v3は互換契約として維持され、`--schema-version`で取得できます。
|
|
142
143
|
|
|
143
144
|
```bash
|
|
144
|
-
lattice plan create --schema-version 4 --json
|
|
145
|
+
lattice plan create --schema-version 4 --json # v4のJSON Schemaを取る(作成はしない)
|
|
145
146
|
```
|
|
146
147
|
|
|
147
148
|
```bash
|
|
@@ -303,8 +304,9 @@ daemonの生死はdescriptor 1枚では持ちません。daemonは自分のpid
|
|
|
303
304
|
配信を続けているdaemonを引き取ります。signalを送るのはその場で再認証を通った相手だけで、応答しない
|
|
304
305
|
pidへは送りません(pid再利用で無関係のprocessを止めうるためです)。規約は
|
|
305
306
|
[ADR 0157](docs/adr/0157-dashboard-daemons-are-discoverable-by-record.md)が正です。
|
|
306
|
-
状態を書き込む`start / block / unblock / done / evidence promote / reopen /
|
|
307
|
-
|
|
307
|
+
状態を書き込む`start / block / unblock / done / evidence promote / reopen / note / independence mode /
|
|
308
|
+
revise / revise-phase / revise-set / phase review|accept|reject|reopen|close-unaudited|baseline /
|
|
309
|
+
dashboard adopt`では、監査actorとして次の3環境変数をすべて設定してください。
|
|
308
310
|
|
|
309
311
|
```bash
|
|
310
312
|
export LATTICE_TODO_ACTOR_HOST=<host-id>
|
package/README.md
CHANGED
|
@@ -78,8 +78,8 @@ lattice todo show --plan <key> --task <id> --json
|
|
|
78
78
|
|
|
79
79
|
The selected ToDo's detail pane shows the initial design memo and the append-only note bodies, plus the
|
|
80
80
|
status of each prerequisite and dependent ToDo (pending, in progress, done, blocked) and whether the ToDo
|
|
81
|
-
can run in parallel.
|
|
82
|
-
|
|
81
|
+
can run in parallel. Note bodies are carried by every rendered surface, including HTML served outside the
|
|
82
|
+
repository. Static per-project HTML is not generated; `lattice todo gantt serve` and the shared dashboard
|
|
83
83
|
read the store dynamically.
|
|
84
84
|
|
|
85
85
|
### The five acceptance conditions
|
|
@@ -120,7 +120,8 @@ the operating AI.
|
|
|
120
120
|
npm install -g @quolu/lattice
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
Requires **Node.js 22.13
|
|
123
|
+
Requires **Node.js 22.13 or newer, except 25.x** (`engines: >=22.13 <25 || >=26`; Node 25's V8
|
|
124
|
+
turboshaft WASM JIT breaks the bundled sensor, so it is blocked with a banner). The structure sensor ships inside the package — there is nothing
|
|
124
125
|
else to install, and Lattice never falls back to a sensor on your `PATH`.
|
|
125
126
|
|
|
126
127
|
## Quick start
|
|
@@ -156,7 +157,7 @@ lattice todo seam-proposal land --plan <key> --names names.json
|
|
|
156
157
|
```
|
|
157
158
|
|
|
158
159
|
Full CLI surface: `lattice --help`, then
|
|
159
|
-
`lattice <plan|run|event|todo|sensor|
|
|
160
|
+
`lattice <plan|run|event|todo|sensor|factory-diagnostics|runtime-errors|bridge|hooks> --help`.
|
|
160
161
|
|
|
161
162
|
## Design principles
|
|
162
163
|
|
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { readTodoStoreStable } from '../src/todo-store.mjs';
|
|
7
7
|
import { TODO_STATUS_DISPATCH_ONLY, projectTodoStatus } from '../src/todo-status.mjs';
|
|
8
8
|
import { ganttLiveHeadDigest, renderTodoGanttForProject } from '../src/todo-cli.mjs';
|
|
9
|
+
import { readProjectExternalPane } from '../src/project-identity.mjs';
|
|
9
10
|
import {
|
|
10
11
|
forgetTodoDashboardDaemonRecord,
|
|
11
12
|
readVisibleTodoDashboardProjects,
|
|
@@ -96,6 +97,10 @@ async function synchronize() {
|
|
|
96
97
|
return {
|
|
97
98
|
html: result.rendered.html,
|
|
98
99
|
head_digest: await ganttLiveHeadDigest({ repoRoot: entry.repo_root, store }),
|
|
100
|
+
// 配信のたびに読む。外部ペインを差した/外した側にdaemon再起動を要求しない。
|
|
101
|
+
external_pane: await readProjectExternalPane({
|
|
102
|
+
repoRoot: entry.repo_root, projectId: entry.project_id,
|
|
103
|
+
}),
|
|
99
104
|
};
|
|
100
105
|
},
|
|
101
106
|
readHead: async () => ganttLiveHeadDigest({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.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",
|
package/src/project-identity.mjs
CHANGED
|
@@ -4,6 +4,9 @@ import { parseTree } from 'jsonc-parser';
|
|
|
4
4
|
|
|
5
5
|
export const PROJECT_IDENTITY_REF = '.lattice/project.json';
|
|
6
6
|
const MAX_BYTES = 65_536;
|
|
7
|
+
const URL_MAX_BYTES = 2_048;
|
|
8
|
+
const IDENTITY_KEYS = 'display_name,project_id,schema';
|
|
9
|
+
const IDENTITY_KEYS_WITH_PANE = 'display_name,external_pane,project_id,schema';
|
|
7
10
|
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
|
8
11
|
|
|
9
12
|
function fail(reason) {
|
|
@@ -30,23 +33,45 @@ function duplicateKey(node) {
|
|
|
30
33
|
return false;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
/** 外部ペインのURLは、閲覧者のブラウザが実際に取りに行く先である。絶対URLだけを通す。 */
|
|
37
|
+
function paneUrl(value) {
|
|
38
|
+
if (typeof value !== 'string' || value.length === 0 || CONTROL.test(value)
|
|
39
|
+
|| Buffer.byteLength(value, 'utf8') > URL_MAX_BYTES) return null;
|
|
40
|
+
let url;
|
|
41
|
+
try { url = new URL(value); } catch { return null; }
|
|
42
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* projectごとに1枚だけ差せる外部ペインの設定。Latticeは中身が何のサービスかを知らない
|
|
48
|
+
* ——題名・埋め込み先URL・生存probe URLの3つだけを受け取る汎用の口である。
|
|
49
|
+
*/
|
|
50
|
+
function externalPane(value) {
|
|
51
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
52
|
+
|| Object.keys(value).sort().join(',') !== 'probe_url,title,url') {
|
|
53
|
+
fail('external_pane_schema_invalid');
|
|
42
54
|
}
|
|
55
|
+
if (!displayName(value.title)) fail('external_pane_title_invalid');
|
|
56
|
+
const url = paneUrl(value.url);
|
|
57
|
+
const probe = paneUrl(value.probe_url);
|
|
58
|
+
if (url === null) fail('external_pane_url_invalid');
|
|
59
|
+
if (probe === null) fail('external_pane_probe_url_invalid');
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
title: value.title,
|
|
62
|
+
url: url.href,
|
|
63
|
+
probeUrl: probe.href,
|
|
64
|
+
frameOrigin: url.origin,
|
|
65
|
+
probeOrigin: probe.origin,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readIdentityDocument({ repoRoot, projectId }) {
|
|
43
70
|
const canonicalRoot = await realpath(repoRoot);
|
|
44
71
|
const ref = path.join(canonicalRoot, PROJECT_IDENTITY_REF);
|
|
45
72
|
let stats;
|
|
46
73
|
try { stats = await lstat(ref); } catch (error) {
|
|
47
|
-
if (error?.code === 'ENOENT')
|
|
48
|
-
return Object.freeze({ projectId, displayName: projectId, source: 'project_id' });
|
|
49
|
-
}
|
|
74
|
+
if (error?.code === 'ENOENT') return null;
|
|
50
75
|
throw error;
|
|
51
76
|
}
|
|
52
77
|
if (!stats.isFile() || stats.isSymbolicLink() || stats.size > MAX_BYTES) fail('identity_file_unsafe');
|
|
@@ -61,11 +86,45 @@ export async function resolveProjectIdentity({ repoRoot, projectId, env = proces
|
|
|
61
86
|
if (errors.length > 0 || tree === undefined || duplicateKey(tree)) fail('identity_json_invalid');
|
|
62
87
|
let document;
|
|
63
88
|
try { document = JSON.parse(text); } catch { fail('identity_json_invalid'); }
|
|
64
|
-
|
|
65
|
-
|
|
89
|
+
const keys = document === null || typeof document !== 'object' || Array.isArray(document)
|
|
90
|
+
? '' : Object.keys(document).sort().join(',');
|
|
91
|
+
if ((keys !== IDENTITY_KEYS && keys !== IDENTITY_KEYS_WITH_PANE)
|
|
66
92
|
|| document.schema !== 'lattice.project_identity.v1'
|
|
67
93
|
|| document.project_id !== projectId || !displayName(document.display_name)) {
|
|
68
94
|
fail('identity_schema_invalid');
|
|
69
95
|
}
|
|
96
|
+
return document;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function resolveProjectIdentity({ repoRoot, projectId, env = process.env }) {
|
|
100
|
+
if (!path.isAbsolute(repoRoot) || typeof projectId !== 'string' || projectId.length === 0
|
|
101
|
+
|| env === null || typeof env !== 'object' || Array.isArray(env)) {
|
|
102
|
+
throw new TypeError('project identity options invalid');
|
|
103
|
+
}
|
|
104
|
+
const override = env.LATTICE_PROJECT_DISPLAY_NAME;
|
|
105
|
+
if (override !== undefined) {
|
|
106
|
+
if (!displayName(override)) fail('environment_display_name_invalid');
|
|
107
|
+
return Object.freeze({ projectId, displayName: override, source: 'environment' });
|
|
108
|
+
}
|
|
109
|
+
const document = await readIdentityDocument({ repoRoot, projectId });
|
|
110
|
+
if (document === null) {
|
|
111
|
+
return Object.freeze({ projectId, displayName: projectId, source: 'project_id' });
|
|
112
|
+
}
|
|
113
|
+
// external_paneは表示名の出典ではない。ここでは形の妥当性だけ確かめて落とす
|
|
114
|
+
// ——壊れた設定を黙って無視すると、差したはずのペインが出ない理由が追えなくなる。
|
|
115
|
+
if (document.external_pane !== undefined) externalPane(document.external_pane);
|
|
70
116
|
return Object.freeze({ projectId, displayName: document.display_name, source: 'project_file' });
|
|
71
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 差してあるなら外部ペイン設定を、無ければnullを返す。配信のたびに読むので、
|
|
121
|
+
* 設定を書いた/消した側はdaemonの再起動を要さない(reloadで反映される)。
|
|
122
|
+
*/
|
|
123
|
+
export async function readProjectExternalPane({ repoRoot, projectId }) {
|
|
124
|
+
if (!path.isAbsolute(repoRoot) || typeof projectId !== 'string' || projectId.length === 0) {
|
|
125
|
+
throw new TypeError('project identity options invalid');
|
|
126
|
+
}
|
|
127
|
+
const document = await readIdentityDocument({ repoRoot, projectId });
|
|
128
|
+
if (document === null || document.external_pane === undefined) return null;
|
|
129
|
+
return externalPane(document.external_pane);
|
|
130
|
+
}
|
package/src/todo-cli.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
ensureTodoDashboardActivity,
|
|
28
28
|
removeTodoDashboardProject,
|
|
29
29
|
} from './todo-dashboard-registry.mjs';
|
|
30
|
-
import { resolveProjectIdentity } from './project-identity.mjs';
|
|
30
|
+
import { readProjectExternalPane, resolveProjectIdentity } from './project-identity.mjs';
|
|
31
31
|
import { layoutTodoGantt } from './todo-gantt-layout.mjs';
|
|
32
32
|
import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
|
|
33
33
|
import { loadTodoGanttPresentation } from './todo-gantt-presentation.mjs';
|
|
@@ -2299,7 +2299,7 @@ async function seamProposalsForGantt({ repoRoot, store }) {
|
|
|
2299
2299
|
|
|
2300
2300
|
export async function renderTodoGanttForProject({
|
|
2301
2301
|
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
2302
|
-
scope = DEFAULT_GANTT_SCOPE,
|
|
2302
|
+
scope = DEFAULT_GANTT_SCOPE,
|
|
2303
2303
|
}) {
|
|
2304
2304
|
const store = readModel
|
|
2305
2305
|
?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
|
|
@@ -2312,8 +2312,7 @@ export async function renderTodoGanttForProject({
|
|
|
2312
2312
|
const [independence, seamProposals, notes] = await Promise.all([
|
|
2313
2313
|
independenceForGantt({ repoRoot, store }),
|
|
2314
2314
|
seamProposalsForGantt({ repoRoot, store }),
|
|
2315
|
-
|
|
2316
|
-
: { contexts: null, warnings: [], headBindings: [] },
|
|
2315
|
+
notesForGantt({ repoRoot, store }),
|
|
2317
2316
|
]);
|
|
2318
2317
|
const layout = layoutTodoGantt(store, chain, { scope, independence, seamProposals });
|
|
2319
2318
|
// When the diagram hides history, the page also carries the full diagram so
|
|
@@ -2366,16 +2365,6 @@ export async function renderTodoGanttForProject({
|
|
|
2366
2365
|
return { store, metadata, memberBindings, rendered };
|
|
2367
2366
|
}
|
|
2368
2367
|
|
|
2369
|
-
/**
|
|
2370
|
-
* 公開配信面はToDo本体の設計メモを表示し、append-only作業記録は除外する。
|
|
2371
|
-
*
|
|
2372
|
-
* loopbackのdashboardと`gantt serve`は公開配信面ではない。作業者本人が読む面なので
|
|
2373
|
-
* 記録込みで描く。この入口はrepo外へHTMLを出す面が生えた時に使う。
|
|
2374
|
-
*/
|
|
2375
|
-
export async function renderPublicTodoGanttForProject(options = {}) {
|
|
2376
|
-
return renderTodoGanttForProject({ ...options, includeNotes: false });
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
2368
|
async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_SCOPE }) {
|
|
2380
2369
|
const initialStore = await readTodoStoreStable({ repoRoot });
|
|
2381
2370
|
const identity = await resolveProjectIdentity({ repoRoot, projectId: initialStore.project_id, env });
|
|
@@ -2388,7 +2377,9 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
|
|
|
2388
2377
|
const { rendered } = await renderTodoGanttForProject({
|
|
2389
2378
|
repoRoot, stable: true, displayName: identity.displayName, scope, readModel: store,
|
|
2390
2379
|
});
|
|
2391
|
-
return { html: rendered.html, head_digest: await ganttLiveHeadDigest({ repoRoot, store })
|
|
2380
|
+
return { html: rendered.html, head_digest: await ganttLiveHeadDigest({ repoRoot, store }),
|
|
2381
|
+
// ローカルの動的dashboardも公開daemonと同じ口を通す(毎描画で読むので差し外しが即反映される)。
|
|
2382
|
+
external_pane: await readProjectExternalPane({ repoRoot, projectId: store.project_id }) };
|
|
2392
2383
|
},
|
|
2393
2384
|
readHead: async () => ganttLiveHeadDigest({
|
|
2394
2385
|
repoRoot, store: await readTodoStoreStable({ repoRoot }),
|
package/src/todo-gantt-live.mjs
CHANGED
|
@@ -4,6 +4,9 @@ import { TODO_DASHBOARD_CODE_VERSION } from './todo-dashboard-registry.mjs';
|
|
|
4
4
|
|
|
5
5
|
const LOOPBACK = '127.0.0.1';
|
|
6
6
|
const POLL_MS = 500;
|
|
7
|
+
const SSE_HEARTBEAT_MS = 25_000;
|
|
8
|
+
const SSE_STALE_MS = 62_500;
|
|
9
|
+
const SSE_WATCHDOG_MS = 12_500;
|
|
7
10
|
const HTTP_ERROR_SCHEMA = 'lattice.todo_gantt_http_error.v1';
|
|
8
11
|
const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
9
12
|
|
|
@@ -11,16 +14,63 @@ function projectPath(projectId) {
|
|
|
11
14
|
return `/projects/${encodeURIComponent(projectId)}/`;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 注入点が消えていたら黙って注入をやめない。タブが出ない理由を後から追えるように、
|
|
19
|
+
* 差せなかった事実をtyped errorとして立てる(差し込み位置は描画部品の実装に依る)。
|
|
20
|
+
*/
|
|
21
|
+
function injectOnce(html, marker, replacement) {
|
|
22
|
+
const at = html.indexOf(marker);
|
|
23
|
+
if (at === -1 || html.indexOf(marker, at + marker.length) !== -1) {
|
|
24
|
+
const error = new Error(`external pane injection point not unique: ${marker}`);
|
|
25
|
+
error.code = 'EXTERNAL_PANE_INJECTION_FAILED';
|
|
26
|
+
error.detail = { marker };
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
return `${html.slice(0, at)}${replacement}${html.slice(at + marker.length)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const EXTERNAL_PANE_STYLE = '<style>.narrative-pane{display:flex;flex-direction:column}[data-right-panel="external"]{display:flex;flex:1 1 auto;min-height:0}[data-right-panel="external"]>iframe{flex:1 1 auto;width:100%;min-height:0;border:0;background:var(--surface-1)}</style>';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 生存判定は「非空の一覧を返すこと」だけを根拠にする。Latticeは差された先が何の
|
|
36
|
+
* サービスかを知らないので、payloadの意味には触らない(非空配列、または配列値の
|
|
37
|
+
* プロパティが1つ以上非空)。非200・fetch失敗・空一覧はすべて概要タブのままにする。
|
|
38
|
+
*/
|
|
39
|
+
function externalPaneController(pane) {
|
|
40
|
+
return `<script>(()=>{const root=document.querySelector('[data-gantt-root]');const panel=root.querySelector('[data-right-panel="external"]');const frame=panel.querySelector('iframe');const content=root.querySelector('.right-content');const button=root.querySelector('[data-show-external-pane]');const show=()=>{if(frame.getAttribute('src')===null)frame.setAttribute('src',frame.dataset.src);content.hidden=true;panel.hidden=false;button.setAttribute('aria-pressed','true');root.dataset.viewState='external';};const leave=()=>{if(root.dataset.viewState==='external')return;panel.hidden=true;content.hidden=false;button.setAttribute('aria-pressed','false');};new MutationObserver(leave).observe(root,{attributeFilter:['data-view-state']});button.addEventListener('click',show);const nonEmpty=value=>Array.isArray(value)&&value.length>0;fetch(${JSON.stringify(pane.probeUrl)},{cache:'no-store'}).then(response=>response.ok?response.json():null).then(payload=>{if(nonEmpty(payload)||(payload!==null&&typeof payload==='object'&&Object.values(payload).some(nonEmpty)))show();}).catch(()=>{});})();</script>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* projectに外部ペインが差してある時だけ、右ペインへタブ1枚とiframeを足す。
|
|
45
|
+
* Latticeは題名・埋め込み先・生存probeの3つしか知らない(差す側が明示的に書く口)。
|
|
46
|
+
*/
|
|
47
|
+
function withExternalPane(html, pane) {
|
|
48
|
+
const tab = `<button type="button" data-show-external-pane aria-pressed="false">${escapeHtml(pane.title)}</button>`;
|
|
49
|
+
const iframe = `<section data-right-panel="external" hidden><iframe data-src="${escapeHtml(pane.url)}" title="${escapeHtml(pane.title)}"></iframe></section>`;
|
|
50
|
+
return injectOnce(
|
|
51
|
+
injectOnce(
|
|
52
|
+
injectOnce(
|
|
53
|
+
injectOnce(html, "default-src 'none'; connect-src 'self';",
|
|
54
|
+
`default-src 'none'; connect-src 'self' ${pane.probeOrigin}; frame-src ${pane.frameOrigin};`),
|
|
55
|
+
'</head>', `${EXTERNAL_PANE_STYLE}</head>`,
|
|
56
|
+
),
|
|
57
|
+
'<button type="button" data-show-overview>概要</button>', `${tab}<button type="button" data-show-overview>概要</button>`,
|
|
58
|
+
),
|
|
59
|
+
'<div class="right-content">', `${iframe}<div class="right-content">`,
|
|
60
|
+
).replace('</body>', `${externalPaneController(pane)}</body>`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function liveHtml(html, headDigest, eventsPath, externalPane = null) {
|
|
64
|
+
const controller = `<script>(()=>{const badge=document.createElement('div');badge.setAttribute('role','status');badge.style.cssText='position:fixed;right:12px;bottom:12px;z-index:99;padding:6px 10px;border:1px solid #d9d8d4;border-radius:4px;background:#fcfcfb;font:600 12px system-ui';badge.textContent='進捗: 接続中';document.body.append(badge);const head=${JSON.stringify(headDigest)};let lastReceipt=Date.now();let stream=null;const receive=event=>{const next=JSON.parse(event.data);if(typeof next.head_digest!=='string')return;lastReceipt=Date.now();badge.textContent='進捗: 最新';if(next.head_digest!==head){badge.textContent='進捗: 更新を反映中';location.reload();}};const connect=()=>{if(stream)stream.close();badge.textContent='進捗: 接続中';stream=new EventSource(${JSON.stringify(eventsPath)});stream.onopen=()=>{lastReceipt=Date.now();};stream.addEventListener('state',receive);stream.addEventListener('ping',receive);stream.addEventListener('lattice-error',event=>{lastReceipt=Date.now();const detail=JSON.parse(event.data);badge.textContent='進捗: エラー '+detail.code;badge.style.borderColor='#d03b3b';});stream.onerror=()=>{badge.textContent='進捗: 再接続中';};};connect();setInterval(()=>{if(Date.now()-lastReceipt>${SSE_STALE_MS})connect();},${SSE_WATCHDOG_MS});})();</script>`;
|
|
16
65
|
const publicMetadata = '<meta name="description" content="Latticeで管理しているプロジェクトの依存工程と進捗を確認できます。"><meta name="robots" content="noindex, nofollow"><meta name="theme-color" content="#f7f3ea">';
|
|
17
66
|
const publicStyle = '<style>body[data-gantt-root]{grid-template-rows:auto minmax(0,1fr)}.lattice-live-brand{z-index:10;display:flex;align-items:center;gap:8px;min-width:0;padding:10px 16px;border-bottom:1px solid #d8d0c5;background:#f7f3ea;color:#6c655d;font:600 12px/1.5 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}.lattice-live-brand a{color:#201d19;text-decoration:none}.lattice-live-brand a:hover{color:#315cbe}.lattice-live-brand strong{color:#201d19}.lattice-live-brand-note{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.lattice-live-back{margin-left:auto!important;color:#315cbe!important;font-weight:750}@media(max-width:560px){.lattice-live-brand{padding:9px 12px}.lattice-live-brand-note{display:none}}</style>';
|
|
18
67
|
const publicHeader = '<header class="lattice-live-brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong><span class="lattice-live-brand-note">公開工程表</span><a class="lattice-live-back" href="/projects/">一覧へ戻る</a></header>';
|
|
19
|
-
|
|
68
|
+
const live = html
|
|
20
69
|
.replace("default-src 'none';", "default-src 'none'; connect-src 'self';")
|
|
21
70
|
.replace('</head>', `${publicMetadata}${publicStyle}</head>`)
|
|
22
71
|
.replace(/<body([^>]*)>/u, `<body$1>${publicHeader}`)
|
|
23
72
|
.replace('</body>', `${controller}</body>`);
|
|
73
|
+
return externalPane === null ? live : withExternalPane(live, externalPane);
|
|
24
74
|
}
|
|
25
75
|
|
|
26
76
|
function escapeHtml(value) {
|
|
@@ -137,6 +187,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
137
187
|
|| typeof registry.list !== 'function') throw new TypeError('registry required');
|
|
138
188
|
const clientsByProject = new Map();
|
|
139
189
|
const lastHeads = new Map();
|
|
190
|
+
const lastHeartbeats = new Map();
|
|
140
191
|
let eventId = 0;
|
|
141
192
|
let checking = false;
|
|
142
193
|
let closed = false;
|
|
@@ -147,6 +198,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
147
198
|
for (const client of clients) client.end();
|
|
148
199
|
clientsByProject.delete(projectId);
|
|
149
200
|
lastHeads.delete(projectId);
|
|
201
|
+
lastHeartbeats.delete(projectId);
|
|
150
202
|
}
|
|
151
203
|
};
|
|
152
204
|
const unsubscribe = typeof registry.subscribe === 'function'
|
|
@@ -208,6 +260,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
208
260
|
try {
|
|
209
261
|
const head = await project.readHead();
|
|
210
262
|
lastHeads.set(project.projectId, head);
|
|
263
|
+
if (!lastHeartbeats.has(project.projectId)) lastHeartbeats.set(project.projectId, Date.now());
|
|
211
264
|
sendEvent(response, 'state', ++eventId, { head_digest: head });
|
|
212
265
|
} catch (error) {
|
|
213
266
|
sendEvent(response, 'lattice-error', ++eventId, { code: error?.code ?? 'STORE_READ_FAILED' });
|
|
@@ -221,7 +274,9 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
221
274
|
displayName: project.displayName,
|
|
222
275
|
});
|
|
223
276
|
lastHeads.set(project.projectId, rendered.head_digest);
|
|
224
|
-
|
|
277
|
+
// 外部ペイン設定は描画のたびにrender側が読み直す。差した/外した側は再起動を要さない。
|
|
278
|
+
const html = liveHtml(rendered.html, rendered.head_digest,
|
|
279
|
+
`${projectPath(project.projectId)}events`, rendered.external_pane ?? null);
|
|
225
280
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8',
|
|
226
281
|
'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
227
282
|
response.end(html);
|
|
@@ -252,6 +307,11 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
252
307
|
lastHeads.set(project.projectId, head);
|
|
253
308
|
for (const client of clients) sendEvent(client, 'state', ++eventId, { head_digest: head });
|
|
254
309
|
}
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
if (now - (lastHeartbeats.get(project.projectId) ?? now) >= SSE_HEARTBEAT_MS) {
|
|
312
|
+
lastHeartbeats.set(project.projectId, now);
|
|
313
|
+
for (const client of clients) sendEvent(client, 'ping', ++eventId, { head_digest: head });
|
|
314
|
+
}
|
|
255
315
|
} catch (error) {
|
|
256
316
|
for (const client of clients) sendEvent(client, 'lattice-error', ++eventId,
|
|
257
317
|
{ code: error?.code ?? 'STORE_READ_FAILED' });
|