@quolu/lattice 0.48.0 → 0.50.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 +12 -10
- package/README.md +5 -4
- package/bin/lattice-dashboard.mjs +5 -0
- package/package.json +1 -1
- package/src/cli-help.mjs +4 -1
- package/src/project-identity.mjs +73 -14
- package/src/todo-cli.mjs +31 -15
- package/src/todo-dashboard-registry.mjs +47 -3
- package/src/todo-gantt-live.mjs +52 -3
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.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",
|
package/src/cli-help.mjs
CHANGED
|
@@ -75,6 +75,7 @@ Read commands:
|
|
|
75
75
|
|
|
76
76
|
Write commands:
|
|
77
77
|
dashboard adopt --json # 衝突したproject_idの配信元rootを現在repoへ明示的に移す
|
|
78
|
+
dashboard remove <project_id> --json # 登録簿から1件外す(対象repoの外からも叩ける)
|
|
78
79
|
note --plan <key> [--task <id>] (--message <text>|--input <file>)
|
|
79
80
|
# ToDoへ作業継続に必要な方針・調査結果・注意をappend-onlyで追記する
|
|
80
81
|
migrate --input <extraction.json> [--serialization-reviewed]
|
|
@@ -200,8 +201,10 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
200
201
|
'todo snapshot': 'todo snapshot --rebuild --plan <key>',
|
|
201
202
|
'todo gantt': 'todo gantt serve --port <port> [--scope live|all] # 動的表示のみ。静的HTML生成は廃止',
|
|
202
203
|
'todo gantt serve': 'todo gantt serve --port <0..65535> [--scope live|all] # loopback動的viewer',
|
|
203
|
-
'todo dashboard': 'todo dashboard adopt --json
|
|
204
|
+
'todo dashboard': 'todo dashboard adopt --json | remove <project_id> --json'
|
|
205
|
+
+ ' # 配信元rootの衝突を明示的に解消/登録簿から1件外す',
|
|
204
206
|
'todo dashboard adopt': 'todo dashboard adopt --json # 現在repoをproject_idの配信元として明示採用',
|
|
207
|
+
'todo dashboard remove': 'todo dashboard remove <project_id> --json # 登録簿から1件外す(対象repo不在でも可)',
|
|
205
208
|
'todo phase': 'todo phase <status|review|accept|reject|reopen|close-unaudited> --plan <key> [options]'
|
|
206
209
|
+ ' | baseline --reason <text> [--except <plan_key>]...',
|
|
207
210
|
'todo phase status': 'todo phase status --plan <key>',
|
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
|
@@ -25,8 +25,9 @@ import { projectTodoChainV1 } from './todo-chain.mjs';
|
|
|
25
25
|
import {
|
|
26
26
|
adoptTodoDashboardActivity,
|
|
27
27
|
ensureTodoDashboardActivity,
|
|
28
|
+
removeTodoDashboardProject,
|
|
28
29
|
} from './todo-dashboard-registry.mjs';
|
|
29
|
-
import { resolveProjectIdentity } from './project-identity.mjs';
|
|
30
|
+
import { readProjectExternalPane, resolveProjectIdentity } from './project-identity.mjs';
|
|
30
31
|
import { layoutTodoGantt } from './todo-gantt-layout.mjs';
|
|
31
32
|
import { TODO_GANTT_SCOPES } from './todo-gantt-scope.mjs';
|
|
32
33
|
import { loadTodoGanttPresentation } from './todo-gantt-presentation.mjs';
|
|
@@ -2298,7 +2299,7 @@ async function seamProposalsForGantt({ repoRoot, store }) {
|
|
|
2298
2299
|
|
|
2299
2300
|
export async function renderTodoGanttForProject({
|
|
2300
2301
|
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
2301
|
-
scope = DEFAULT_GANTT_SCOPE,
|
|
2302
|
+
scope = DEFAULT_GANTT_SCOPE,
|
|
2302
2303
|
}) {
|
|
2303
2304
|
const store = readModel
|
|
2304
2305
|
?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
|
|
@@ -2311,8 +2312,7 @@ export async function renderTodoGanttForProject({
|
|
|
2311
2312
|
const [independence, seamProposals, notes] = await Promise.all([
|
|
2312
2313
|
independenceForGantt({ repoRoot, store }),
|
|
2313
2314
|
seamProposalsForGantt({ repoRoot, store }),
|
|
2314
|
-
|
|
2315
|
-
: { contexts: null, warnings: [], headBindings: [] },
|
|
2315
|
+
notesForGantt({ repoRoot, store }),
|
|
2316
2316
|
]);
|
|
2317
2317
|
const layout = layoutTodoGantt(store, chain, { scope, independence, seamProposals });
|
|
2318
2318
|
// When the diagram hides history, the page also carries the full diagram so
|
|
@@ -2365,16 +2365,6 @@ export async function renderTodoGanttForProject({
|
|
|
2365
2365
|
return { store, metadata, memberBindings, rendered };
|
|
2366
2366
|
}
|
|
2367
2367
|
|
|
2368
|
-
/**
|
|
2369
|
-
* 公開配信面はToDo本体の設計メモを表示し、append-only作業記録は除外する。
|
|
2370
|
-
*
|
|
2371
|
-
* loopbackのdashboardと`gantt serve`は公開配信面ではない。作業者本人が読む面なので
|
|
2372
|
-
* 記録込みで描く。この入口はrepo外へHTMLを出す面が生えた時に使う。
|
|
2373
|
-
*/
|
|
2374
|
-
export async function renderPublicTodoGanttForProject(options = {}) {
|
|
2375
|
-
return renderTodoGanttForProject({ ...options, includeNotes: false });
|
|
2376
|
-
}
|
|
2377
|
-
|
|
2378
2368
|
async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_SCOPE }) {
|
|
2379
2369
|
const initialStore = await readTodoStoreStable({ repoRoot });
|
|
2380
2370
|
const identity = await resolveProjectIdentity({ repoRoot, projectId: initialStore.project_id, env });
|
|
@@ -2387,7 +2377,9 @@ async function serveGantt({ repoRoot, port, stdout, env, scope = DEFAULT_GANTT_S
|
|
|
2387
2377
|
const { rendered } = await renderTodoGanttForProject({
|
|
2388
2378
|
repoRoot, stable: true, displayName: identity.displayName, scope, readModel: store,
|
|
2389
2379
|
});
|
|
2390
|
-
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 }) };
|
|
2391
2383
|
},
|
|
2392
2384
|
readHead: async () => ganttLiveHeadDigest({
|
|
2393
2385
|
repoRoot, store: await readTodoStoreStable({ repoRoot }),
|
|
@@ -2568,6 +2560,30 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2568
2560
|
}
|
|
2569
2561
|
}
|
|
2570
2562
|
|
|
2563
|
+
// 登録を外したいprojectのrepoは、もう存在しないことが普通である(それが外す理由になる)。
|
|
2564
|
+
// 通常dispatchより前に処理し、repoRoot解決・store読取・dashboard daemon起動を経由させない。
|
|
2565
|
+
if (argv.length === 4 && argv[0] === 'dashboard' && argv[1] === 'remove'
|
|
2566
|
+
&& isTodoIdentifier(argv[2]) && argv[3] === '--json') {
|
|
2567
|
+
try {
|
|
2568
|
+
await removeTodoDashboardProject({ projectId: argv[2], env });
|
|
2569
|
+
const result = {
|
|
2570
|
+
schema: 'lattice.todo_dashboard_remove_result.v1',
|
|
2571
|
+
project_id: argv[2],
|
|
2572
|
+
removed: true,
|
|
2573
|
+
result_digest: '',
|
|
2574
|
+
};
|
|
2575
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
2576
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
2577
|
+
return 0;
|
|
2578
|
+
} catch (error) {
|
|
2579
|
+
if (typeof error?.code === 'string' && error.detail !== null
|
|
2580
|
+
&& typeof error.detail === 'object') return typedFailure(stderr, error);
|
|
2581
|
+
return typedFailure(stderr, {
|
|
2582
|
+
code: 'INTERNAL_FAILURE', message: error?.constructor?.name ?? 'Error',
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2571
2587
|
let action = null;
|
|
2572
2588
|
if ((argv.length === 1 && argv[0] === 'status')
|
|
2573
2589
|
|| (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import {
|
|
5
|
-
mkdir, open, readdir, readFile, realpath, rename, rm, writeFile,
|
|
5
|
+
mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile,
|
|
6
6
|
} from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
@@ -157,6 +157,17 @@ export async function readVisibleTodoDashboardProjects({
|
|
|
157
157
|
|| left.project_id.localeCompare(right.project_id, 'en'));
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* 見るのはrepo_root directoryの存在だけ。`.lattice`の有無で判定すると、storeを持たない
|
|
162
|
+
* 生きたrepoまで登録簿から消える。ENOENT/ENOTDIR以外のerrorは「消えた証拠」ではないので
|
|
163
|
+
* 生存扱いのまま残す——判定できない相手を消す方が損害が大きい。
|
|
164
|
+
*/
|
|
165
|
+
async function repoRootPresent(ref) {
|
|
166
|
+
try { return (await stat(ref)).isDirectory(); } catch (error) {
|
|
167
|
+
return !['ENOENT', 'ENOTDIR'].includes(error?.code);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
export async function registerTodoDashboardActivity({
|
|
161
172
|
repoRoot, projectId, displayName = projectId, sessionId, env = process.env,
|
|
162
173
|
now = new Date(), adopt = false,
|
|
@@ -169,6 +180,7 @@ export async function registerTodoDashboardActivity({
|
|
|
169
180
|
const canonicalRoot = await realpath(repoRoot);
|
|
170
181
|
const refs = paths(env);
|
|
171
182
|
await mkdir(refs.root, { recursive: true, mode: 0o700 });
|
|
183
|
+
const pruned = [];
|
|
172
184
|
await withLock(refs.lock, async () => {
|
|
173
185
|
const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
|
|
174
186
|
const existing = current.projects.find((entry) => entry.project_id === projectId);
|
|
@@ -183,13 +195,45 @@ export async function registerTodoDashboardActivity({
|
|
|
183
195
|
};
|
|
184
196
|
throw error;
|
|
185
197
|
}
|
|
186
|
-
|
|
198
|
+
// 死んだ登録を落とす機会はここしかない。登録は全sessionが必ず通る一点であり、
|
|
199
|
+
// 掃除を「人がコマンドを叩いた時」に置くと、誰も叩かないので永久に積み上がる。
|
|
200
|
+
const projects = [];
|
|
201
|
+
for (const entry of current.projects) {
|
|
202
|
+
if (entry.project_id === projectId) continue;
|
|
203
|
+
if (await repoRootPresent(entry.repo_root)) projects.push(entry);
|
|
204
|
+
else pruned.push(entry.project_id);
|
|
205
|
+
}
|
|
187
206
|
projects.push({ project_id: projectId, display_name: displayName, repo_root: canonicalRoot,
|
|
188
207
|
session_id: sessionId, last_seen_at: now.toISOString() });
|
|
189
208
|
projects.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
|
|
209
|
+
pruned.sort((left, right) => left.localeCompare(right, 'en'));
|
|
210
|
+
await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
|
|
211
|
+
});
|
|
212
|
+
return { projectId, displayName, repoRoot: canonicalRoot, sessionId, adopted: adopt, pruned };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 登録簿から1件を明示的に外す唯一の入口。対象repoが既に消えていても叩けるよう、
|
|
217
|
+
* storeもdaemonも経由しない。該当が無いのは暗黙の成功にしない——「消したつもり」で
|
|
218
|
+
* 別のproject_idが残り続ける状態を、成功の応答で隠すことになる。
|
|
219
|
+
*/
|
|
220
|
+
export async function removeTodoDashboardProject({ projectId, env = process.env }) {
|
|
221
|
+
if (!identifier(projectId)) throw new TypeError('dashboard project id is invalid');
|
|
222
|
+
const refs = paths(env);
|
|
223
|
+
await mkdir(refs.root, { recursive: true, mode: 0o700 });
|
|
224
|
+
await withLock(refs.lock, async () => {
|
|
225
|
+
const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
|
|
226
|
+
const projects = current.projects.filter((entry) => entry.project_id !== projectId);
|
|
227
|
+
if (projects.length === current.projects.length) {
|
|
228
|
+
const error = new Error('dashboard project is not registered');
|
|
229
|
+
error.code = 'PROJECT_NOT_REGISTERED';
|
|
230
|
+
// 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで対象を特定できる。
|
|
231
|
+
error.detail = { project_id: projectId };
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
190
234
|
await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
|
|
191
235
|
});
|
|
192
|
-
return { projectId,
|
|
236
|
+
return { projectId, removed: true };
|
|
193
237
|
}
|
|
194
238
|
|
|
195
239
|
/** 配信元rootを移す唯一の明示入口。通常activity登録はこのflagを立てない。 */
|
package/src/todo-gantt-live.mjs
CHANGED
|
@@ -11,16 +11,63 @@ function projectPath(projectId) {
|
|
|
11
11
|
return `/projects/${encodeURIComponent(projectId)}/`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* 注入点が消えていたら黙って注入をやめない。タブが出ない理由を後から追えるように、
|
|
16
|
+
* 差せなかった事実をtyped errorとして立てる(差し込み位置は描画部品の実装に依る)。
|
|
17
|
+
*/
|
|
18
|
+
function injectOnce(html, marker, replacement) {
|
|
19
|
+
const at = html.indexOf(marker);
|
|
20
|
+
if (at === -1 || html.indexOf(marker, at + marker.length) !== -1) {
|
|
21
|
+
const error = new Error(`external pane injection point not unique: ${marker}`);
|
|
22
|
+
error.code = 'EXTERNAL_PANE_INJECTION_FAILED';
|
|
23
|
+
error.detail = { marker };
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
return `${html.slice(0, at)}${replacement}${html.slice(at + marker.length)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
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>';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 生存判定は「非空の一覧を返すこと」だけを根拠にする。Latticeは差された先が何の
|
|
33
|
+
* サービスかを知らないので、payloadの意味には触らない(非空配列、または配列値の
|
|
34
|
+
* プロパティが1つ以上非空)。非200・fetch失敗・空一覧はすべて概要タブのままにする。
|
|
35
|
+
*/
|
|
36
|
+
function externalPaneController(pane) {
|
|
37
|
+
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>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* projectに外部ペインが差してある時だけ、右ペインへタブ1枚とiframeを足す。
|
|
42
|
+
* Latticeは題名・埋め込み先・生存probeの3つしか知らない(差す側が明示的に書く口)。
|
|
43
|
+
*/
|
|
44
|
+
function withExternalPane(html, pane) {
|
|
45
|
+
const tab = `<button type="button" data-show-external-pane aria-pressed="false">${escapeHtml(pane.title)}</button>`;
|
|
46
|
+
const iframe = `<section data-right-panel="external" hidden><iframe data-src="${escapeHtml(pane.url)}" title="${escapeHtml(pane.title)}"></iframe></section>`;
|
|
47
|
+
return injectOnce(
|
|
48
|
+
injectOnce(
|
|
49
|
+
injectOnce(
|
|
50
|
+
injectOnce(html, "default-src 'none'; connect-src 'self';",
|
|
51
|
+
`default-src 'none'; connect-src 'self' ${pane.probeOrigin}; frame-src ${pane.frameOrigin};`),
|
|
52
|
+
'</head>', `${EXTERNAL_PANE_STYLE}</head>`,
|
|
53
|
+
),
|
|
54
|
+
'<button type="button" data-show-overview>概要</button>', `${tab}<button type="button" data-show-overview>概要</button>`,
|
|
55
|
+
),
|
|
56
|
+
'<div class="right-content">', `${iframe}<div class="right-content">`,
|
|
57
|
+
).replace('</body>', `${externalPaneController(pane)}</body>`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function liveHtml(html, headDigest, eventsPath, externalPane = null) {
|
|
15
61
|
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);let head=${JSON.stringify(headDigest)};const stream=new EventSource(${JSON.stringify(eventsPath)});stream.addEventListener('state',event=>{const next=JSON.parse(event.data);badge.textContent='進捗: 最新';if(next.head_digest!==head){badge.textContent='進捗: 更新を反映中';location.reload();}});stream.addEventListener('lattice-error',event=>{const detail=JSON.parse(event.data);badge.textContent='進捗: エラー '+detail.code;badge.style.borderColor='#d03b3b';});stream.onerror=()=>{badge.textContent='進捗: 再接続中';};})();</script>`;
|
|
16
62
|
const publicMetadata = '<meta name="description" content="Latticeで管理しているプロジェクトの依存工程と進捗を確認できます。"><meta name="robots" content="noindex, nofollow"><meta name="theme-color" content="#f7f3ea">';
|
|
17
63
|
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
64
|
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
|
-
|
|
65
|
+
const live = html
|
|
20
66
|
.replace("default-src 'none';", "default-src 'none'; connect-src 'self';")
|
|
21
67
|
.replace('</head>', `${publicMetadata}${publicStyle}</head>`)
|
|
22
68
|
.replace(/<body([^>]*)>/u, `<body$1>${publicHeader}`)
|
|
23
69
|
.replace('</body>', `${controller}</body>`);
|
|
70
|
+
return externalPane === null ? live : withExternalPane(live, externalPane);
|
|
24
71
|
}
|
|
25
72
|
|
|
26
73
|
function escapeHtml(value) {
|
|
@@ -221,7 +268,9 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
221
268
|
displayName: project.displayName,
|
|
222
269
|
});
|
|
223
270
|
lastHeads.set(project.projectId, rendered.head_digest);
|
|
224
|
-
|
|
271
|
+
// 外部ペイン設定は描画のたびにrender側が読み直す。差した/外した側は再起動を要さない。
|
|
272
|
+
const html = liveHtml(rendered.html, rendered.head_digest,
|
|
273
|
+
`${projectPath(project.projectId)}events`, rendered.external_pane ?? null);
|
|
225
274
|
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8',
|
|
226
275
|
'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
227
276
|
response.end(html);
|