@quolu/lattice 0.34.2 → 0.35.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 +26 -3
- package/bin/lattice.mjs +6 -2
- package/package.json +1 -1
- package/src/cli-help.mjs +10 -4
- package/src/project-cli.mjs +23 -2
- package/src/todo-cli.mjs +121 -12
- package/src/todo-dispatch-shape.mjs +190 -0
- package/src/todo-gantt-live.mjs +21 -2
package/README.ja.md
CHANGED
|
@@ -161,12 +161,35 @@ cross-plan topologyを同時に切り替える場合は
|
|
|
161
161
|
Phase付きv5 planでは、通常ToDoの開始順はToDo DAGだけで決まり、Phase前後関係は重監査の順序だけを
|
|
162
162
|
制御します。特定ToDoがPhase受理を本当に必要とする場合だけ`phase_accept_dependencies`で明示します。
|
|
163
163
|
`lattice todo status --json`の`dispatch_frontier`はready全件を同時dispatchする既定を示します。
|
|
164
|
-
readyが複数なら最初のstartに`--parallel-frontier
|
|
165
|
-
`--override-reason <reason
|
|
164
|
+
readyが複数なら最初のstartに`--parallel-frontier`を付けます。subsetだけを直列着手する場合は
|
|
165
|
+
`--override-reason <reason>`で理由を残しますが、**その申告は一度突き返されます**。
|
|
166
166
|
|
|
167
167
|
```bash
|
|
168
168
|
lattice todo start --plan <key> --task <id> --parallel-frontier
|
|
169
|
-
lattice todo start --plan <key> --task <id> --override-reason <reason>
|
|
169
|
+
lattice todo start --plan <key> --task <id> --override-reason <reason> --serial-confirmed
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
直列の申告に対して`PARALLEL_DISPATCH_RECONSIDER`を返し、並列の再検討を促してから、
|
|
173
|
+
同じ理由に`--serial-confirmed`を付けた再実行だけを通します。規則を文書へ書くだけでは
|
|
174
|
+
読み飛ばされるため、再考をコマンドの往復で強制する設計です。**足止めは一度だけで、
|
|
175
|
+
再実行すれば直列で進みます。**
|
|
176
|
+
|
|
177
|
+
ただし理由が**実際の干渉を述べていない**場合——「単一セッションだから」「逐次実行するので」
|
|
178
|
+
のようにworker数・セッション構成・作業者の都合を述べただけの場合——は
|
|
179
|
+
`PARALLEL_DISPATCH_INVALID`で拒否し、`--serial-confirmed`を付けても通しません。
|
|
180
|
+
実行主体が1つしか無いことは並列にできない理由ではない(必要ならworkerを増やす)ためです。
|
|
181
|
+
「両taskが同一fileへ書き込む」のような干渉を書けば、再確認を経て通ります。
|
|
182
|
+
|
|
183
|
+
**同じ検査を計画時点にも掛けます。** 着手時だけを締めても、planそのものが直列に組まれていれば
|
|
184
|
+
並列は生まれません。`lattice plan create`と`lattice todo migrate`は依存グラフから
|
|
185
|
+
`dispatch_shape`(`task_count`/`critical_path_length`/`max_frontier_width`/
|
|
186
|
+
`serialization_ratio`)を計算して結果へ載せ、直列度が閾値を超えるplanを一度突き返します。
|
|
187
|
+
再考した上でなお直列でよいなら`--serialization-reviewed`を付けて再実行します
|
|
188
|
+
(6 task未満のplanは対象外)。判定はstore書込みの前に行うので、拒否された時にstoreへは
|
|
189
|
+
何も書かれません。
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
lattice todo migrate --input <extraction.json> --serialization-reviewed
|
|
170
193
|
```
|
|
171
194
|
|
|
172
195
|
`--parallel-frontier`はhostへ並列dispatch方針を宣言する開始gateです。Lattice自身がAI hostのagentを
|
package/bin/lattice.mjs
CHANGED
|
@@ -30,10 +30,14 @@ if (help !== null) {
|
|
|
30
30
|
cwd: process.cwd(), stdout: process.stdout, cliVersion: packageJson.version, error,
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
|
-
} else if (args.length === 4
|
|
33
|
+
} else if ((args.length === 4 || args.length === 5) && args[0] === 'plan' && args[1] === 'create'
|
|
34
|
+
&& args[2] === '--input' && (args.length === 4 || args[4] === '--serialization-reviewed')) {
|
|
34
35
|
const { projectCliFailure, runPlanCreate } = await import('../src/project-cli.mjs');
|
|
35
36
|
try {
|
|
36
|
-
process.exitCode = await runPlanCreate({
|
|
37
|
+
process.exitCode = await runPlanCreate({
|
|
38
|
+
cwd: process.cwd(), inputRef: args[3], stdout: process.stdout,
|
|
39
|
+
serializationReviewed: args.length === 5,
|
|
40
|
+
});
|
|
37
41
|
} catch (error) {
|
|
38
42
|
process.exitCode = projectCliFailure(process.stderr, error);
|
|
39
43
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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
|
@@ -23,7 +23,9 @@ const NAMESPACE_HELP = Object.freeze({
|
|
|
23
23
|
plan: `Usage: lattice plan <command> [options]
|
|
24
24
|
|
|
25
25
|
Commands:
|
|
26
|
-
create --input <file>
|
|
26
|
+
create --input <file> [--serialization-reviewed]
|
|
27
|
+
# 依存グラフがほぼ一直線(serialization_ratioが閾値超)なら一度突き返す。
|
|
28
|
+
# 再考した上でなお直列でよいなら --serialization-reviewed を付けて再実行する
|
|
27
29
|
create --schema --json
|
|
28
30
|
create --schema-version <2|3> --json
|
|
29
31
|
compile --request <request.json>
|
|
@@ -70,7 +72,11 @@ Read commands:
|
|
|
70
72
|
phase status --plan <key>
|
|
71
73
|
|
|
72
74
|
Write commands:
|
|
73
|
-
|
|
75
|
+
migrate --input <extraction.json> [--serialization-reviewed]
|
|
76
|
+
# 既存storeへplanを追加する(plan createは空store初期化専用)。
|
|
77
|
+
# 依存グラフがほぼ一直線なら一度突き返し、再考後の --serialization-reviewed で通す
|
|
78
|
+
start --plan <key> --task <id> [--parallel-frontier|--override-reason <text> [--serial-confirmed]]
|
|
79
|
+
# 既定は全ready同時dispatch。直列にするには理由の申告後、再考を経て --serial-confirmed が要る
|
|
74
80
|
block --plan <key> --task <id> --reason <text>
|
|
75
81
|
unblock --plan <key> --task <id>
|
|
76
82
|
done --plan <key> --task <id> --evidence <file>
|
|
@@ -129,7 +135,7 @@ registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPT
|
|
|
129
135
|
const SUBCOMMAND_USAGE = Object.freeze({
|
|
130
136
|
status: 'status --json',
|
|
131
137
|
'session-context': 'session-context --json',
|
|
132
|
-
'plan create': 'plan create --input <file> | --schema --json | --schema-version <2|3> --json',
|
|
138
|
+
'plan create': 'plan create --input <file> [--serialization-reviewed] | --schema --json | --schema-version <2|3> --json',
|
|
133
139
|
'plan compile': 'plan compile --request <request.json> | --schema --json',
|
|
134
140
|
'plan verify': 'plan verify --request <request.json> --plan <plan.json>',
|
|
135
141
|
'run start': 'run start --request <request.json> --executor <adapter> | --schema --json',
|
|
@@ -174,7 +180,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
174
180
|
'todo revise': 'todo revise --plan <key> --input <file>',
|
|
175
181
|
'todo revise-phase': 'todo revise-phase --plan <key> --input <file>',
|
|
176
182
|
'todo revise-set': 'todo revise-set --input <file>',
|
|
177
|
-
'todo migrate': 'todo migrate --input <extraction.json>',
|
|
183
|
+
'todo migrate': 'todo migrate --input <extraction.json> [--serialization-reviewed]',
|
|
178
184
|
'sensor init': 'sensor init [path] --json',
|
|
179
185
|
'sensor sync': 'sensor sync [path] --json',
|
|
180
186
|
'runtime-errors snapshot': 'runtime-errors snapshot [--after-cursor <n>] [--limit <n>] --json',
|
package/src/project-cli.mjs
CHANGED
|
@@ -26,6 +26,10 @@ import {
|
|
|
26
26
|
readTodoStore,
|
|
27
27
|
TodoStoreError,
|
|
28
28
|
} from './todo-store.mjs';
|
|
29
|
+
import {
|
|
30
|
+
assertTodoDispatchShapeReviewed,
|
|
31
|
+
computeTodoDispatchShapeForPlan,
|
|
32
|
+
} from './todo-dispatch-shape.mjs';
|
|
29
33
|
|
|
30
34
|
const STORE_REF = '.lattice/todo';
|
|
31
35
|
const MANIFEST_REF = `${STORE_REF}/manifest.json`;
|
|
@@ -432,11 +436,21 @@ function validateCreateInput(value) {
|
|
|
432
436
|
} catch { return false; }
|
|
433
437
|
}
|
|
434
438
|
|
|
435
|
-
export async function runPlanCreate({ cwd, inputRef, stdout }) {
|
|
439
|
+
export async function runPlanCreate({ cwd, inputRef, stdout, serializationReviewed = false }) {
|
|
436
440
|
const repoRoot = resolveRepoRoot(cwd);
|
|
437
441
|
if (repoRoot === null) throw new TodoStoreError('REPO_UNRESOLVED', 'git_toplevel_unresolved');
|
|
438
442
|
const input = await readCanonicalInput(repoRoot, inputRef);
|
|
439
443
|
if (!validateCreateInput(input)) throw new TodoStoreError('INPUT_INVALID', 'plan_create_schema_invalid');
|
|
444
|
+
// dispatch_shapeのgateはstore初期化より前に判定する(拒否時にstoreへ何も書かないため、
|
|
445
|
+
// 再考後の再実行がplan_key_already_existsで詰まらない)。
|
|
446
|
+
const dispatchShape = computeTodoDispatchShapeForPlan({
|
|
447
|
+
projectId: input.project_id,
|
|
448
|
+
planKey: input.plan_key,
|
|
449
|
+
taskIds: input.tasks.map(({ task_id: taskId }) => taskId),
|
|
450
|
+
hardDependencies: input.hard_dependencies,
|
|
451
|
+
joins: input.joins,
|
|
452
|
+
});
|
|
453
|
+
assertTodoDispatchShapeReviewed({ shape: dispatchShape, reviewed: serializationReviewed });
|
|
440
454
|
const store = await initializeAuthoredTodoStore({
|
|
441
455
|
repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
|
|
442
456
|
projectId: input.project_id, repositories: [{ repo_id: 'self', path: '.' }],
|
|
@@ -460,7 +474,14 @@ export async function runPlanCreate({ cwd, inputRef, stdout }) {
|
|
|
460
474
|
plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
|
|
461
475
|
store_ref: STORE_REF, plan_ref: member.descriptor.plan_ref,
|
|
462
476
|
journal_ref: member.descriptor.journal_ref, snapshot_ref: member.descriptor.snapshot_ref,
|
|
463
|
-
plan_digest: member.plan.plan_digest,
|
|
477
|
+
plan_digest: member.plan.plan_digest,
|
|
478
|
+
dispatch_shape: {
|
|
479
|
+
task_count: dispatchShape.task_count,
|
|
480
|
+
critical_path_length: dispatchShape.critical_path_length,
|
|
481
|
+
max_frontier_width: dispatchShape.max_frontier_width,
|
|
482
|
+
serialization_ratio: dispatchShape.serialization_ratio,
|
|
483
|
+
},
|
|
484
|
+
result_digest: '',
|
|
464
485
|
};
|
|
465
486
|
result.result_digest = resultDigest(result);
|
|
466
487
|
stdout.write(`${JSON.stringify(result)}\n`);
|
package/src/todo-cli.mjs
CHANGED
|
@@ -54,6 +54,10 @@ import {
|
|
|
54
54
|
appendTodoExtraction,
|
|
55
55
|
validateTodoExtraction,
|
|
56
56
|
} from './todo-migration.mjs';
|
|
57
|
+
import {
|
|
58
|
+
assertTodoDispatchShapeReviewed,
|
|
59
|
+
computeTodoDispatchShapeForPlan,
|
|
60
|
+
} from './todo-dispatch-shape.mjs';
|
|
57
61
|
import {
|
|
58
62
|
computeReadyFrontier,
|
|
59
63
|
projectTodoBindings,
|
|
@@ -477,7 +481,32 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
|
|
|
477
481
|
};
|
|
478
482
|
}
|
|
479
483
|
|
|
480
|
-
|
|
484
|
+
// 直列化の理由として認めない定型句。
|
|
485
|
+
// worker数・セッション構成・作業者の都合は「並列にできない根拠」ではない。
|
|
486
|
+
// 根拠になるのは実際の干渉だけ(同一fileへの書込衝突・外部資源の排他・順序依存)。
|
|
487
|
+
// 単一プロセスのagentが「自分は1人だから」と直列へ逃げる事例が実運用で出たため、
|
|
488
|
+
// frontierの既定(all_ready_parallel_by_default)を宣言だけでなく機構で守る。
|
|
489
|
+
const SERIAL_NON_REASONS = [
|
|
490
|
+
/単一(?:の)?(?:セッション|エージェント|worker|ワーカー|プロセス|スレッド)/u,
|
|
491
|
+
/(?:逐次|順次|直列|シリアル)(?:実行|処理|化|に|で)/u,
|
|
492
|
+
/(?:一人|1人|ひとり|1名|単独)(?:で|の|しか)/u,
|
|
493
|
+
/(?:サブ)?エージェント(?:が|は)?(?:居ない|いない|使わない|使えない)/u,
|
|
494
|
+
/single[-\s]?(?:session|agent|worker|process|thread)/iu,
|
|
495
|
+
/\b(?:sequential|serial)(?:ly)?\s*(?:execution|processing|run|dispatch)?\b/iu,
|
|
496
|
+
/one[-\s]at[-\s]a[-\s]time|\bsolo\b|\bby myself\b/iu,
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 直列化理由が「実際の干渉」を述べているかを検査する。
|
|
501
|
+
* worker数・セッション構成を述べただけの理由は根拠にならないので拒否する。
|
|
502
|
+
*/
|
|
503
|
+
function serialReasonNonInterference(reason) {
|
|
504
|
+
return SERIAL_NON_REASONS.find((pattern) => pattern.test(reason)) ?? null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function startTask({
|
|
508
|
+
repoRoot, env, planKey, taskId, overrideReason, parallelFrontier, serialConfirmed = false,
|
|
509
|
+
}) {
|
|
481
510
|
const store = await readTodoStore({ repoRoot });
|
|
482
511
|
const projection = projectTodoStatus(store);
|
|
483
512
|
const readyTask = projection.next_ready.find((task) => (
|
|
@@ -487,16 +516,60 @@ async function startTask({ repoRoot, env, planKey, taskId, overrideReason, paral
|
|
|
487
516
|
if (parallelFrontier && !targetReady) {
|
|
488
517
|
throw new TodoStoreError('PARALLEL_DISPATCH_INVALID', 'parallel_frontier_not_applicable');
|
|
489
518
|
}
|
|
490
|
-
|
|
491
|
-
&&
|
|
519
|
+
const frontierContested = targetReady && projection.active_set.length === 0
|
|
520
|
+
&& projection.next_ready.length > 1;
|
|
521
|
+
if (frontierContested && overrideReason === null && !parallelFrontier) {
|
|
492
522
|
throw new TodoStoreError('PARALLEL_DISPATCH_REQUIRED', 'parallel_frontier_requires_declaration',
|
|
493
523
|
undefined, {
|
|
494
524
|
ready_count: projection.next_ready.length,
|
|
525
|
+
ready_task_ids: projection.next_ready.map((task) => task.task_id),
|
|
495
526
|
frontier_digest: projection.dispatch_frontier.frontier_digest,
|
|
496
527
|
parallel_start_flag: projection.dispatch_frontier.parallel_start_flag,
|
|
497
528
|
serial_reason_flag: '--override-reason',
|
|
529
|
+
default_policy: projection.dispatch_frontier.policy,
|
|
530
|
+
guidance: '既定は全ready分の同時dispatch。並列で始めるなら --parallel-frontier を使う。'
|
|
531
|
+
+ '--override-reason は「なぜ並列にできないか」を書く欄であり、'
|
|
532
|
+
+ 'worker数・セッション構成・作業者の都合は根拠にならない'
|
|
533
|
+
+ '(同一fileへの書込衝突・外部資源の排他・順序依存だけが根拠になる)。',
|
|
498
534
|
});
|
|
499
535
|
}
|
|
536
|
+
if (frontierContested && overrideReason !== null) {
|
|
537
|
+
if (serialReasonNonInterference(overrideReason) !== null) {
|
|
538
|
+
throw new TodoStoreError('PARALLEL_DISPATCH_INVALID', 'serial_reason_is_not_an_interference',
|
|
539
|
+
undefined, {
|
|
540
|
+
ready_count: projection.next_ready.length,
|
|
541
|
+
ready_task_ids: projection.next_ready.map((task) => task.task_id),
|
|
542
|
+
rejected_reason: overrideReason,
|
|
543
|
+
default_policy: projection.dispatch_frontier.policy,
|
|
544
|
+
parallel_start_flag: projection.dispatch_frontier.parallel_start_flag,
|
|
545
|
+
guidance: 'worker数・セッション構成・作業者の都合は直列化の根拠にならない。'
|
|
546
|
+
+ 'readyが複数あるなら既定は同時dispatchであり、実行主体が1つしか無いことは'
|
|
547
|
+
+ '並列にできない理由ではない(必要ならworkerを増やす)。'
|
|
548
|
+
+ '直列にするなら、並列で走らせたときに実際に起きる干渉'
|
|
549
|
+
+ '(同一fileへの書込衝突・外部資源の排他・順序依存)を書く。',
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
// 直列の申告は一度突き返して並列を再検討させる。
|
|
553
|
+
// 規則を書くだけでは読み飛ばされるため、再考をコマンドの往復で強制する。
|
|
554
|
+
if (!serialConfirmed) {
|
|
555
|
+
throw new TodoStoreError('PARALLEL_DISPATCH_RECONSIDER', 'consider_parallel_before_serial',
|
|
556
|
+
undefined, {
|
|
557
|
+
ready_count: projection.next_ready.length,
|
|
558
|
+
ready_task_ids: projection.next_ready.map((task) => task.task_id),
|
|
559
|
+
declared_reason: overrideReason,
|
|
560
|
+
default_policy: projection.dispatch_frontier.policy,
|
|
561
|
+
parallel_start_flag: projection.dispatch_frontier.parallel_start_flag,
|
|
562
|
+
serial_confirm_flag: '--serial-confirmed',
|
|
563
|
+
guidance: `並列を検討しなさい。ready ${projection.next_ready.length} 件は同時に着手できる`
|
|
564
|
+
+ '前提で並んでおり、既定は全件同時dispatchである。'
|
|
565
|
+
+ `まず ${projection.dispatch_frontier.parallel_start_flag} で全readyを起こし、`
|
|
566
|
+
+ 'それぞれ別のworkerへ渡すことを検討する'
|
|
567
|
+
+ '(実行主体が足りないなら増やす。増やせないことは並列にできない理由ではない)。'
|
|
568
|
+
+ '検討した上でなお直列にするなら、同じ --override-reason に'
|
|
569
|
+
+ ' --serial-confirmed を付けて再実行する。',
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
500
573
|
const resolvedTaskId = readyTask?.task_id ?? taskId;
|
|
501
574
|
// 助言はjournalへ書く前に確定させる。計算できないならstart自体を止める。
|
|
502
575
|
const advisory = await startAdvisory({
|
|
@@ -569,10 +642,34 @@ async function phaseStatus({ repoRoot, planKey }) {
|
|
|
569
642
|
return result;
|
|
570
643
|
}
|
|
571
644
|
|
|
572
|
-
async function migrate({ repoRoot, inputRef }) {
|
|
645
|
+
async function migrate({ repoRoot, inputRef, serializationReviewed = false }) {
|
|
573
646
|
const extraction = await readMigrationInput(repoRoot, inputRef);
|
|
574
|
-
|
|
647
|
+
// dispatch_shapeのgateはappendTodoExtraction(store書込み)より前に判定する必要がある
|
|
648
|
+
// (拒否時にstoreへ何も書かないため、再考後の再実行がplan_key_already_existsで
|
|
649
|
+
// 詰まらない)。unresolved/空集合の2つの早期gateは、compileTodoExtraction内部の
|
|
650
|
+
// 同名gateをここでも先に通しておくことで、既存のエラー優先順位
|
|
651
|
+
// (unresolved・空集合を直列度より先に報告する)を変えない。
|
|
652
|
+
const unresolvedTaskIds = extraction.tasks
|
|
653
|
+
.filter(({ disposition }) => disposition === 'unknown_requires_evidence')
|
|
654
|
+
.map(({ task_id: taskId }) => taskId);
|
|
655
|
+
if (unresolvedTaskIds.length > 0) {
|
|
656
|
+
throw new TodoStoreError('MIGRATION_UNRESOLVED', 'unknown_requires_evidence', undefined, {
|
|
657
|
+
task_ids: unresolvedTaskIds,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
575
660
|
const registered = extraction.tasks.filter(({ disposition }) => disposition.startsWith('register_'));
|
|
661
|
+
if (registered.length === 0) throw new TodoStoreError('MIGRATION_EMPTY', 'no_registered_tasks');
|
|
662
|
+
|
|
663
|
+
const dispatchShape = computeTodoDispatchShapeForPlan({
|
|
664
|
+
projectId: extraction.project_id,
|
|
665
|
+
planKey: extraction.plan_key,
|
|
666
|
+
taskIds: registered.map(({ task_id: taskId }) => taskId),
|
|
667
|
+
hardDependencies: extraction.hard_dependencies,
|
|
668
|
+
joins: extraction.joins,
|
|
669
|
+
});
|
|
670
|
+
assertTodoDispatchShapeReviewed({ shape: dispatchShape, reviewed: serializationReviewed });
|
|
671
|
+
|
|
672
|
+
const imported = await appendTodoExtraction({ repoRoot, extraction });
|
|
576
673
|
const result = {
|
|
577
674
|
schema: 'lattice.todo_migrate_result.v1',
|
|
578
675
|
project_id: imported.plan.project_id,
|
|
@@ -586,6 +683,12 @@ async function migrate({ repoRoot, inputRef }) {
|
|
|
586
683
|
snapshot_ref: imported.descriptor.snapshot_ref,
|
|
587
684
|
topology_digest: imported.plan.topology_digest,
|
|
588
685
|
journal_head_digest: imported.events.at(-1).event_digest,
|
|
686
|
+
dispatch_shape: {
|
|
687
|
+
task_count: dispatchShape.task_count,
|
|
688
|
+
critical_path_length: dispatchShape.critical_path_length,
|
|
689
|
+
max_frontier_width: dispatchShape.max_frontier_width,
|
|
690
|
+
serialization_ratio: dispatchShape.serialization_ratio,
|
|
691
|
+
},
|
|
589
692
|
result_digest: '',
|
|
590
693
|
};
|
|
591
694
|
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
@@ -1913,9 +2016,11 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
1913
2016
|
action = (repoRoot) => serveGantt({
|
|
1914
2017
|
repoRoot, port: Number(argv[3]), stdout, env, scope: argv[5],
|
|
1915
2018
|
});
|
|
1916
|
-
} else if (argv.length === 3 && argv[0] === 'migrate' && argv[1] === '--input'
|
|
1917
|
-
&& isTodoRef(argv[2])) {
|
|
1918
|
-
action = (repoRoot) => migrate({
|
|
2019
|
+
} else if ((argv.length === 3 || argv.length === 4) && argv[0] === 'migrate' && argv[1] === '--input'
|
|
2020
|
+
&& isTodoRef(argv[2]) && (argv.length === 3 || argv[3] === '--serialization-reviewed')) {
|
|
2021
|
+
action = (repoRoot) => migrate({
|
|
2022
|
+
repoRoot, inputRef: argv[2], serializationReviewed: argv.length === 4,
|
|
2023
|
+
});
|
|
1919
2024
|
} else if (argv.length === 5 && argv[0] === 'revise'
|
|
1920
2025
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1921
2026
|
&& argv[3] === '--input' && isTodoRef(argv[4])) {
|
|
@@ -1950,14 +2055,18 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
1950
2055
|
&& (argv.length === 8 || (argv[8] === '--override-reason' && argv[9].length > 0))) {
|
|
1951
2056
|
action = (repoRoot) => phaseMutation({ repoRoot, env, planKey: argv[3], phaseId: argv[5],
|
|
1952
2057
|
kind: 'phase_reopen', payload: { reason: argv[7], override_reason: argv[9] ?? null } });
|
|
1953
|
-
} else if ((argv.length === 5 || argv.length === 6 || argv.length === 7
|
|
2058
|
+
} else if ((argv.length === 5 || argv.length === 6 || argv.length === 7 || argv.length === 8)
|
|
2059
|
+
&& argv[0] === 'start'
|
|
1954
2060
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1955
2061
|
&& argv[3] === '--task' && isTodoIdentifier(argv[4])
|
|
1956
2062
|
&& (argv.length === 5 || (argv.length === 6 && argv[5] === '--parallel-frontier')
|
|
1957
|
-
|| (argv.length === 7
|
|
1958
|
-
|
|
2063
|
+
|| ((argv.length === 7 || argv.length === 8)
|
|
2064
|
+
&& argv[5] === '--override-reason' && argv[6].length > 0
|
|
2065
|
+
&& (argv.length === 7 || argv[7] === '--serial-confirmed')))) {
|
|
2066
|
+
const overrideReason = argv.length >= 7 ? argv[6] : null;
|
|
1959
2067
|
action = (repoRoot) => startTask({ repoRoot, env, planKey: argv[2], taskId: argv[4],
|
|
1960
|
-
overrideReason, parallelFrontier: argv.length === 6
|
|
2068
|
+
overrideReason, parallelFrontier: argv.length === 6,
|
|
2069
|
+
serialConfirmed: argv.length === 8 });
|
|
1961
2070
|
} else if (argv.length === 7 && argv[0] === 'block'
|
|
1962
2071
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
1963
2072
|
&& argv[3] === '--task' && isTodoIdentifier(argv[4])
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { TodoStoreError } from './todo-store.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 依存グラフの「直列度」を判定する既定閾値。
|
|
5
|
+
*
|
|
6
|
+
* `serialization_ratio = critical_path_length / task_count` がこれを超えると、
|
|
7
|
+
* 依存連鎖が全task数の半分を超えて連なっている=大半のtaskが並列候補ではなく
|
|
8
|
+
* 一本の鎖に押し込まれていることを意味する。`todo start`側が既に持つ
|
|
9
|
+
* `all_ready_parallel_by_default`方針をplan作成時点まで前倒しする出発点として
|
|
10
|
+
* 0.5を採る。実測(parent-child-repair: task 26, critical path約20,
|
|
11
|
+
* ratio≈0.77)を確実に超える一方、緩やかな分岐を持つ通常のplanまでは拾わない
|
|
12
|
+
* 水準として選んだ。
|
|
13
|
+
*/
|
|
14
|
+
export const DISPATCH_SHAPE_SERIALIZATION_THRESHOLD = 0.5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 閾値判定の対象にする最小task数。
|
|
18
|
+
* 3〜5 task程度の一直線planを毎回突き返しても再考の余地がなく
|
|
19
|
+
* (並列化する意味のある規模でない)機構がノイズになるだけなので、
|
|
20
|
+
* 6 task未満は常に素通りさせる。
|
|
21
|
+
*/
|
|
22
|
+
export const DISPATCH_SHAPE_MIN_TASK_COUNT_FOR_GATE = 6;
|
|
23
|
+
|
|
24
|
+
export const DISPATCH_SHAPE_SERIALIZATION_REVIEWED_FLAG = '--serialization-reviewed';
|
|
25
|
+
|
|
26
|
+
function compareText(left, right) {
|
|
27
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* task_id集合と(既にこの集合の内側だけへ絞り込まれた)依存辺から、
|
|
32
|
+
* dispatch形状を計算する。
|
|
33
|
+
*
|
|
34
|
+
* 呼び出し側の責務: `edges`は「このtask集合の内側だけ」の task_id 対で渡すこと
|
|
35
|
+
* (cross-plan/既存planへの依存や、joinの`after→before`展開は呼び出し側で
|
|
36
|
+
* 行い、範囲外の参照はここへ渡さない)。範囲外の参照が混入した場合は
|
|
37
|
+
* 呼び出し側の実装誤りとして typed error で止める。
|
|
38
|
+
*
|
|
39
|
+
* 循環検出は専用のロジックを別途書き足すのではなく、最長path計算
|
|
40
|
+
* (Kahn法によるtopological order)が全nodeを消化できないことの自然な帰結
|
|
41
|
+
* として行う。この関数はplan作成/migrateがstoreへ書き込む前(拒否時に
|
|
42
|
+
* 何も書かない設計)に呼ばれるため、store側の`validateMergedGraph`による
|
|
43
|
+
* cycle拒否より前に走る——結果として、循環を含む入力はここで先に
|
|
44
|
+
* `DISPATCH_SHAPE_INVALID`として止まる(従来store書込み時に出ていた
|
|
45
|
+
* `STORE_INCONSISTENT`/`merged_cycle`より手前で検出されるようになるという、
|
|
46
|
+
* 観測可能だが意図した違いがある)。
|
|
47
|
+
*/
|
|
48
|
+
export function computeTodoDispatchShape({ taskIds, edges }) {
|
|
49
|
+
if (!Array.isArray(taskIds) || taskIds.length === 0
|
|
50
|
+
|| !taskIds.every((id) => typeof id === 'string' && id.length > 0)) {
|
|
51
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_task_ids_invalid');
|
|
52
|
+
}
|
|
53
|
+
const idSet = new Set(taskIds);
|
|
54
|
+
if (idSet.size !== taskIds.length) {
|
|
55
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_task_ids_duplicate');
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(edges) || edges.some((edge) => edge === null || typeof edge !== 'object'
|
|
58
|
+
|| typeof edge.from !== 'string' || typeof edge.to !== 'string'
|
|
59
|
+
|| !idSet.has(edge.from) || !idSet.has(edge.to))) {
|
|
60
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_edge_out_of_scope');
|
|
61
|
+
}
|
|
62
|
+
if (edges.some((edge) => edge.from === edge.to)) {
|
|
63
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_self_edge');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const successors = new Map([...idSet].map((id) => [id, []]));
|
|
67
|
+
const indegree = new Map([...idSet].map((id) => [id, 0]));
|
|
68
|
+
const edgeKeys = new Set();
|
|
69
|
+
for (const { from, to } of edges) {
|
|
70
|
+
const key = `${from}\0${to}`;
|
|
71
|
+
if (edgeKeys.has(key)) continue; // hard_dependenciesとjoin由来で同じ辺が重複しても一度だけ数える
|
|
72
|
+
edgeKeys.add(key);
|
|
73
|
+
successors.get(from).push(to);
|
|
74
|
+
indegree.set(to, indegree.get(to) + 1);
|
|
75
|
+
}
|
|
76
|
+
for (const list of successors.values()) list.sort(compareText);
|
|
77
|
+
|
|
78
|
+
// Kahn法によるtopological order。dist[node] = nodeで終わる最長path長(辺数)は、
|
|
79
|
+
// 「nodeを、その全先行taskが処理済みになった時点で処理する」という不変条件から
|
|
80
|
+
// 標準的なlongest-path-in-DAG漸化式(dist[v] = max(dist[v], dist[u]+1))として導かれる。
|
|
81
|
+
const dist = new Map([...idSet].map((id) => [id, 0]));
|
|
82
|
+
const predecessor = new Map();
|
|
83
|
+
const queue = [...idSet].filter((id) => indegree.get(id) === 0).sort(compareText);
|
|
84
|
+
const order = [];
|
|
85
|
+
while (queue.length > 0) {
|
|
86
|
+
queue.sort(compareText);
|
|
87
|
+
const node = queue.shift();
|
|
88
|
+
order.push(node);
|
|
89
|
+
for (const successor of successors.get(node)) {
|
|
90
|
+
if (dist.get(node) + 1 > dist.get(successor)) {
|
|
91
|
+
dist.set(successor, dist.get(node) + 1);
|
|
92
|
+
predecessor.set(successor, node);
|
|
93
|
+
}
|
|
94
|
+
indegree.set(successor, indegree.get(successor) - 1);
|
|
95
|
+
if (indegree.get(successor) === 0) queue.push(successor);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (order.length !== idSet.size) {
|
|
99
|
+
throw new TodoStoreError('DISPATCH_SHAPE_INVALID', 'dispatch_shape_dependency_cycle');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const taskCount = idSet.size;
|
|
103
|
+
const maxDist = Math.max(...order.map((id) => dist.get(id)));
|
|
104
|
+
const criticalPathLength = maxDist + 1;
|
|
105
|
+
const widthByDist = new Map();
|
|
106
|
+
for (const id of idSet) widthByDist.set(dist.get(id), (widthByDist.get(dist.get(id)) ?? 0) + 1);
|
|
107
|
+
const maxFrontierWidth = Math.max(...widthByDist.values());
|
|
108
|
+
|
|
109
|
+
// critical path(人向けヒント)の復元: distが最大のnodeから、決定的に選んだpredecessorを
|
|
110
|
+
// 遡って根まで辿る。表示専用でありdigest対象ではないため、決定性は再現性のためだけに要る。
|
|
111
|
+
const deepest = [...idSet].filter((id) => dist.get(id) === maxDist).sort(compareText)[0];
|
|
112
|
+
const criticalPathTaskIds = [];
|
|
113
|
+
for (let cursor = deepest; cursor !== undefined; cursor = predecessor.get(cursor)) {
|
|
114
|
+
criticalPathTaskIds.push(cursor);
|
|
115
|
+
}
|
|
116
|
+
criticalPathTaskIds.reverse();
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
task_count: taskCount,
|
|
120
|
+
critical_path_length: criticalPathLength,
|
|
121
|
+
max_frontier_width: maxFrontierWidth,
|
|
122
|
+
// canonical digest(todoSelfDigest→digestTodoArtifact)はsafe integer以外の数値を
|
|
123
|
+
// TypeErrorで拒否する(todo-contracts.mjsのcanonicalPart)。dispatch_shapeは
|
|
124
|
+
// plan create/migrateの結果にそのまま埋め込まれ digest対象になるため、比率は
|
|
125
|
+
// 固定小数のstringで持つ(判定側はNumber()で復元する)。
|
|
126
|
+
serialization_ratio: (criticalPathLength / taskCount).toFixed(4),
|
|
127
|
+
critical_path_task_ids: criticalPathTaskIds,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** dispatch_shapeが、再考なしで直列のまま通してよい規模・度合いかを判定する。 */
|
|
132
|
+
export function isTodoDispatchShapeSerializationExcessive(shape) {
|
|
133
|
+
return shape.task_count >= DISPATCH_SHAPE_MIN_TASK_COUNT_FOR_GATE
|
|
134
|
+
&& Number(shape.serialization_ratio) > DISPATCH_SHAPE_SERIALIZATION_THRESHOLD;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* dispatch_shapeが直列に寄りすぎているplanを、再考なしでは通さない。
|
|
139
|
+
*
|
|
140
|
+
* `todo start`の`PARALLEL_DISPATCH_RECONSIDER`と同じ二段階(一度突き返し、
|
|
141
|
+
* 再考を経たflagが無ければ通さない)をplan作成時点へ前倒しする。ここで
|
|
142
|
+
* 拒否する場合、呼び出し側はまだstoreへ何も書いていないこと(初期化/追加を
|
|
143
|
+
* この呼び出しより後で行うこと)。
|
|
144
|
+
*/
|
|
145
|
+
export function assertTodoDispatchShapeReviewed({ shape, reviewed }) {
|
|
146
|
+
if (!isTodoDispatchShapeSerializationExcessive(shape) || reviewed) return;
|
|
147
|
+
throw new TodoStoreError('PARALLEL_DISPATCH_RECONSIDER', 'plan_shape_too_serial', undefined, {
|
|
148
|
+
task_count: shape.task_count,
|
|
149
|
+
critical_path_length: shape.critical_path_length,
|
|
150
|
+
max_frontier_width: shape.max_frontier_width,
|
|
151
|
+
serialization_ratio: shape.serialization_ratio,
|
|
152
|
+
critical_path_task_ids: shape.critical_path_task_ids,
|
|
153
|
+
default_policy: 'all_ready_parallel_by_default',
|
|
154
|
+
serialization_reviewed_flag: DISPATCH_SHAPE_SERIALIZATION_REVIEWED_FLAG,
|
|
155
|
+
guidance: `並列を検討しなさい。task ${shape.task_count}件のうちcritical pathが`
|
|
156
|
+
+ `${shape.critical_path_length}段(serialization_ratio ${shape.serialization_ratio})で、`
|
|
157
|
+
+ '大半のtaskが並列候補ではなく一本の依存鎖に押し込まれている。'
|
|
158
|
+
+ 'critical_path_task_idsに沿った依存のうち、実際には干渉しない組を'
|
|
159
|
+
+ 'hard_dependencies/joinsから外せないか見直す'
|
|
160
|
+
+ '(実行主体が足りないなら増やす。増やせないことは直列化の理由にならない)。'
|
|
161
|
+
+ `検討した上でなお直列でよいなら ${DISPATCH_SHAPE_SERIALIZATION_REVIEWED_FLAG} を付けて再実行する。`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* plan/extractionの`tasks`・`hard_dependencies`・`joins`(nodeRef形式)から、
|
|
167
|
+
* 「このproject_id/plan_keyの内側だけ」に絞ったdispatch形状を計算する高レベル入口。
|
|
168
|
+
*
|
|
169
|
+
* cross-plan参照やdanglingな参照はここで検証しない(既存のstore書込み経路が
|
|
170
|
+
* 別途検証する)。単に形状計算の対象から外すだけであり、それらの妥当性判断は
|
|
171
|
+
* 呼び出し側の後続処理(実際のstore書込み)に委ねる。
|
|
172
|
+
*/
|
|
173
|
+
export function computeTodoDispatchShapeForPlan({
|
|
174
|
+
projectId, planKey, taskIds, hardDependencies, joins,
|
|
175
|
+
}) {
|
|
176
|
+
const idSet = new Set(taskIds);
|
|
177
|
+
const isLocal = (ref) => ref?.project_id === projectId && ref?.plan_key === planKey
|
|
178
|
+
&& idSet.has(ref?.task_id);
|
|
179
|
+
const edges = [];
|
|
180
|
+
for (const edge of hardDependencies ?? []) {
|
|
181
|
+
if (isLocal(edge.from) && isLocal(edge.to)) edges.push({ from: edge.from.task_id, to: edge.to.task_id });
|
|
182
|
+
}
|
|
183
|
+
for (const join of joins ?? []) {
|
|
184
|
+
if (!isLocal(join.before)) continue;
|
|
185
|
+
for (const after of join.after) {
|
|
186
|
+
if (isLocal(after)) edges.push({ from: after.task_id, to: join.before.task_id });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return computeTodoDispatchShape({ taskIds, edges });
|
|
190
|
+
}
|
package/src/todo-gantt-live.mjs
CHANGED
|
@@ -40,6 +40,13 @@ function dashboardHtml(projects) {
|
|
|
40
40
|
return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="robots" content="noindex, nofollow"><meta property="og:title" content="公開中の工程表 — Lattice"><meta property="og:description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="theme-color" content="#f7f3ea"><title>公開中の工程表 — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,sans-serif}.shell{max-width:880px;margin:0 auto;padding:28px 22px 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a,.footer a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover,.footer a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:64px 0 72px}.eyebrow{margin:0 0 8px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}.lead{max-width:620px;margin:0 0 34px;color:var(--soft)}h1{margin:0 0 14px;font-size:clamp(2rem,6vw,3.4rem);line-height:1.12;letter-spacing:-.04em}ul{display:grid;gap:12px;margin:0;padding:0;list-style:none}li a{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 20px;border:1px solid var(--line);border-radius:12px;color:inherit;background:var(--panel);text-decoration:none;box-shadow:0 8px 28px rgba(48,39,27,.04)}li a:hover{border-color:var(--cobalt);transform:translateY(-1px)}li strong{font-size:1.04rem}li code{color:var(--soft);font-size:.78rem}li span{color:var(--cobalt);font-weight:800}.note{margin:28px 0 0;padding:16px 18px;border-left:3px solid var(--orange);color:var(--soft);background:rgba(255,253,248,.72);font-size:.88rem}.footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;padding-top:20px;border-top:1px solid var(--line);color:var(--soft);font-size:.82rem}.footer nav{display:flex;gap:18px}@media(max-width:560px){.shell{padding:20px 16px 32px}main{padding:44px 0 56px}li a{grid-template-columns:minmax(0,1fr) auto;padding:16px}li code{grid-column:1/-1;grid-row:2}.footer{display:block}.footer nav{margin-top:10px}}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">LIVE DEVELOPMENT</p><h1>公開中の工程表</h1><p class="lead">Latticeが管理しているプロジェクトの工程と、いまどこまで進んでいるかを公開データから確認できます。</p>${content}<p class="note">表示内容はLatticeの記録から自動生成されます。製品の紹介や使い方はGitHubをご覧ください。</p></main><footer class="footer"><span>kitepon.dev の開発工程を、Latticeで可視化しています。</span><nav aria-label="関連リンク"><a href="https://kitepon.dev/">kitepon.dev</a><a href="https://github.com/kitepon-rgb/Lattice">GitHub</a></nav></footer></div></body></html>`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function notFoundHtml(code, path) {
|
|
44
|
+
const reason = code === 'PROJECT_NOT_FOUND'
|
|
45
|
+
? '指定された工程表は、公開を終了したかURLが変わった可能性があります。'
|
|
46
|
+
: '指定されたページは、この公開工程表にはありません。';
|
|
47
|
+
return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex, nofollow"><meta name="theme-color" content="#f7f3ea"><title>ページが見つかりません — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,"Hiragino Sans","Yu Gothic UI",sans-serif}.shell{width:min(720px,calc(100% - 32px));margin:0 auto;padding:28px 0 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:clamp(64px,12vw,112px) 0}.eyebrow{margin:0 0 10px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}h1{margin:0 0 16px;font-size:clamp(2.1rem,7vw,4rem);line-height:1.1;letter-spacing:-.045em}p{max-width:620px;margin:0;color:var(--soft)}code{display:block;margin-top:22px;padding:12px 14px;overflow-wrap:anywhere;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--soft);font-size:.78rem}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:28px}.actions a{display:inline-flex;align-items:center;min-height:44px;padding:0 16px;border:1px solid var(--line);border-radius:8px;color:var(--ink);background:var(--panel);font-weight:750;text-decoration:none}.actions a:first-child{border-color:var(--cobalt);color:#fff;background:var(--cobalt)}.actions a:hover{transform:translateY(-1px)}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">404 · ${escapeHtml(code)}</p><h1>ページが見つかりません</h1><p>${reason}</p><code>${escapeHtml(path)}</code><nav class="actions" aria-label="戻り先"><a href="/projects/">公開工程表の一覧へ</a><a href="https://kitepon.dev/">kitepon.devへ</a></nav></main></div></body></html>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
function validateProject(project) {
|
|
44
51
|
if (project === null || typeof project !== 'object' || Array.isArray(project)
|
|
45
52
|
|| typeof project.projectId !== 'string' || !PROJECT_ID.test(project.projectId)
|
|
@@ -105,6 +112,18 @@ function sendHttpError(response, status, code, path) {
|
|
|
105
112
|
response.end(`${JSON.stringify({ schema: HTTP_ERROR_SCHEMA, code, path })}\n`);
|
|
106
113
|
}
|
|
107
114
|
|
|
115
|
+
function sendNotFound(request, response, code, path) {
|
|
116
|
+
if (!String(request.headers.accept ?? '').toLowerCase().includes('text/html')) {
|
|
117
|
+
sendHttpError(response, 404, code, path);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const html = notFoundHtml(code, path);
|
|
121
|
+
response.writeHead(404, { 'content-type': 'text/html; charset=utf-8',
|
|
122
|
+
'content-length': Buffer.byteLength(html), 'cache-control': 'no-store',
|
|
123
|
+
'x-content-type-options': 'nosniff' });
|
|
124
|
+
response.end(html);
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
function finishRequestFailure(response, code, path) {
|
|
109
128
|
try {
|
|
110
129
|
if (!response.headersSent) sendHttpError(response, 500, code, path);
|
|
@@ -167,7 +186,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
167
186
|
}
|
|
168
187
|
const match = /^\/projects\/([^/]+)\/(events)?$/u.exec(url.pathname);
|
|
169
188
|
if (match === null) {
|
|
170
|
-
|
|
189
|
+
sendNotFound(request, response, 'ROUTE_NOT_FOUND', url.pathname);
|
|
171
190
|
return;
|
|
172
191
|
}
|
|
173
192
|
let requestedId;
|
|
@@ -177,7 +196,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
177
196
|
}
|
|
178
197
|
const project = registry.get(requestedId);
|
|
179
198
|
if (project === null) {
|
|
180
|
-
|
|
199
|
+
sendNotFound(request, response, 'PROJECT_NOT_FOUND', url.pathname);
|
|
181
200
|
return;
|
|
182
201
|
}
|
|
183
202
|
if (match[2] === 'events') {
|