@quolu/lattice 0.50.0 → 0.51.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/bin/lattice-work-order-adapter.mjs +20 -0
- package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -0
- package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -0
- package/package.json +5 -2
- package/src/boundary-observation-compiler-v2.mjs +1 -1
- package/src/cli-help.mjs +29 -2
- package/src/rc3-actual-dogfood.mjs +6 -2
- package/src/rc3-scripted-campaign.mjs +37 -10
- package/src/rc4-stage1-dogfood.mjs +6 -2
- package/src/runtime-adapter-registry.mjs +21 -7
- package/src/runtime-cli.mjs +476 -34
- package/src/runtime-contracts.mjs +59 -13
- package/src/runtime-controller-protocol.mjs +48 -3
- package/src/runtime-decision-verifier.mjs +70 -0
- package/src/runtime-diff-observer.mjs +66 -4
- package/src/runtime-direct-os-observer.mjs +25 -8
- package/src/runtime-driver-state.mjs +162 -0
- package/src/runtime-engine.mjs +37 -6
- package/src/runtime-front-end.mjs +39 -1
- package/src/runtime-managed-supervisor.mjs +80 -14
- package/src/runtime-multi-epoch-store.mjs +87 -14
- package/src/runtime-pull-intake.mjs +1188 -0
- package/src/runtime-work-order-contracts.mjs +91 -0
- package/src/runtime-work-order-controller.mjs +1167 -0
- package/src/seam-proposal-queries.mjs +1 -1
- package/src/todo-cli.mjs +199 -5
- package/src/todo-contracts.mjs +4 -1
- package/src/todo-gantt-html-independence.mjs +3 -2
- package/src/todo-gantt-html-shared.mjs +1 -2
- package/src/todo-gantt-html-style.mjs +13 -0
- package/src/todo-gantt-html.mjs +15 -2
- package/src/todo-gantt-layout.mjs +71 -1
- package/src/todo-gantt-live.mjs +12 -1
- package/src/todo-gantt-nested.mjs +243 -0
- package/src/todo-gantt-svg.mjs +80 -5
- package/src/todo-independence-contracts.mjs +73 -7
- package/src/todo-independence-guidance.mjs +30 -1
- package/src/todo-independence.mjs +89 -7
- package/src/todo-revision.mjs +1 -1
- package/src/todo-split.mjs +472 -0
- package/src/todo-store-git-transaction.mjs +418 -0
- package/src/todo-store.mjs +54 -0
|
@@ -5,7 +5,7 @@ import { SENSOR_QUERY_OPERATIONS } from './runtime-contracts.mjs';
|
|
|
5
5
|
import { collectSensorEvidence, portableSensorOutcome } from './sensor-adapter.mjs';
|
|
6
6
|
import { todoSelfDigest } from './todo-contracts.mjs';
|
|
7
7
|
|
|
8
|
-
const CONFLICT_KINDS = new Set(['symbol', 'path', 'state', 'effect']);
|
|
8
|
+
const CONFLICT_KINDS = new Set(['symbol', 'path', 'state', 'effect', 'line']);
|
|
9
9
|
const QUERYABLE_KINDS = new Set(['symbol', 'path']);
|
|
10
10
|
const SYMBOL_OPERATIONS = Object.freeze(['query', 'callers', 'callees', 'impact']);
|
|
11
11
|
const SENSOR_OPERATIONS = new Set(SENSOR_QUERY_OPERATIONS);
|
package/src/todo-cli.mjs
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
readTodoIndependenceArtifact,
|
|
51
51
|
readTodoSeamProposalArtifact,
|
|
52
52
|
readTodoStore,
|
|
53
|
+
resolveTodoStartRetractionBinding,
|
|
53
54
|
readTodoWitnessSet,
|
|
54
55
|
todoWitnessRef,
|
|
55
56
|
writeTodoWitnessSet,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
verifyEffectivePhaseTodoRevisionSources,
|
|
61
62
|
verifyTodoRevisionSources,
|
|
62
63
|
} from './todo-store.mjs';
|
|
64
|
+
import { withStartRetractionGuard } from './runtime-pull-intake.mjs';
|
|
63
65
|
import {
|
|
64
66
|
appendTodoExtraction,
|
|
65
67
|
compileTodoExtraction,
|
|
@@ -116,6 +118,10 @@ import {
|
|
|
116
118
|
parseTodoSourceRef, todoLegacyReconciliationDigest, validatePhaseTodoRevision,
|
|
117
119
|
validateTodoRevision, validateTodoRevisionSet,
|
|
118
120
|
} from './todo-revision.mjs';
|
|
121
|
+
import {
|
|
122
|
+
compileTodoSplit,
|
|
123
|
+
prepareTodoSplitWitnessMigration,
|
|
124
|
+
} from './todo-split.mjs';
|
|
119
125
|
import {
|
|
120
126
|
appendTodoNote,
|
|
121
127
|
readTodoNoteContext,
|
|
@@ -124,6 +130,7 @@ import {
|
|
|
124
130
|
readTodoPlanNotesForStatus,
|
|
125
131
|
} from './todo-note-store.mjs';
|
|
126
132
|
import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
|
|
133
|
+
import { commitTodoStoreMutation } from './todo-store-git-transaction.mjs';
|
|
127
134
|
|
|
128
135
|
const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
|
|
129
136
|
const DEFAULT_GANTT_SCOPE = 'live';
|
|
@@ -177,7 +184,7 @@ function typedFailure(stderr, error) {
|
|
|
177
184
|
const TODO_COMMAND_NAMES = Object.freeze([
|
|
178
185
|
'status', 'show', 'note', 'bindings', 'independence', 'seam-profile', 'seam-proposal',
|
|
179
186
|
'verify', 'snapshot', 'gantt', 'dashboard', 'phase', 'migrate', 'start', 'block',
|
|
180
|
-
'unblock', 'done', 'reopen', 'evidence', 'revise', 'revise-phase', 'revise-set',
|
|
187
|
+
'unblock', 'done', 'reopen', 'evidence', 'split', 'revise', 'revise-phase', 'revise-set',
|
|
181
188
|
]);
|
|
182
189
|
|
|
183
190
|
function typedArgumentFailure(stderr, code, message, detail) {
|
|
@@ -186,6 +193,30 @@ function typedArgumentFailure(stderr, code, message, detail) {
|
|
|
186
193
|
return 2;
|
|
187
194
|
}
|
|
188
195
|
|
|
196
|
+
function supportsAtomicStoreCommit(argv) {
|
|
197
|
+
const command = argv[0];
|
|
198
|
+
if (command === 'note') return argv[1] !== 'list';
|
|
199
|
+
if (command === 'independence') {
|
|
200
|
+
return argv[1] === 'mode'
|
|
201
|
+
|| (argv[1] === 'witness' && ['migrate', 'scaffold'].includes(argv[2]));
|
|
202
|
+
}
|
|
203
|
+
if (command === 'snapshot') return argv[1] === '--rebuild';
|
|
204
|
+
if (command === 'migrate') return !argv.includes('--dry-run') && !argv.includes('--schema');
|
|
205
|
+
if (['revise', 'split', 'revise-set', 'revise-phase', 'start', 'retract', 'block',
|
|
206
|
+
'unblock', 'done', 'reopen'].includes(command)) return true;
|
|
207
|
+
if (command === 'evidence') return argv[1] === 'promote';
|
|
208
|
+
if (command === 'phase') return argv[1] !== 'status';
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function atomicStoreCommitUnsupported(stderr, argv) {
|
|
213
|
+
return typedArgumentFailure(stderr, 'STORE_COMMIT_UNSUPPORTED',
|
|
214
|
+
'todo_command_does_not_mutate_only_the_store', {
|
|
215
|
+
command: argv.slice(0, 3),
|
|
216
|
+
next_action: 'remove_--commit-store_or_use_a_supported_todo_write_command',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
189
220
|
function resolveRepoRoot(cwd) {
|
|
190
221
|
try {
|
|
191
222
|
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
@@ -786,6 +817,23 @@ async function startTask({
|
|
|
786
817
|
payload: { override_reason: overrideReason }, evidenceRef: null, advisory, noteContext });
|
|
787
818
|
}
|
|
788
819
|
|
|
820
|
+
async function retractStart({ repoRoot, env, planKey, taskId, reason }) {
|
|
821
|
+
const actor = mutationActor(env);
|
|
822
|
+
const store = await readTodoStore({ repoRoot });
|
|
823
|
+
const binding = resolveTodoStartRetractionBinding(store, { planKey, taskId, actor });
|
|
824
|
+
return withStartRetractionGuard({
|
|
825
|
+
repoRoot,
|
|
826
|
+
planKey,
|
|
827
|
+
taskId: binding.task_id,
|
|
828
|
+
activationEventDigest: binding.activation_event_digest,
|
|
829
|
+
action: () => mutate({
|
|
830
|
+
repoRoot, env, planKey, taskId: binding.task_id, kind: 'start_retracted',
|
|
831
|
+
payload: { reason, target_start_digest: binding.activation_event_digest },
|
|
832
|
+
evidenceRef: null,
|
|
833
|
+
}),
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
789
837
|
function validatePhaseDecisionInput(value, outcome) {
|
|
790
838
|
const keys = outcome === 'accept'
|
|
791
839
|
? ['schema', 'review_event_digest', 'decision_evidence', 'evidence_slots', 'input_digest']
|
|
@@ -1322,6 +1370,69 @@ async function revisePhase({ repoRoot, env, planKey, inputRef }) {
|
|
|
1322
1370
|
revision, actor: mutationActor(env), recordedAt: new Date().toISOString() });
|
|
1323
1371
|
}
|
|
1324
1372
|
|
|
1373
|
+
async function splitTodo({ repoRoot, env, planKey, inputRef }) {
|
|
1374
|
+
const proposal = await readMigrationInput(repoRoot, inputRef, { requireValid: false });
|
|
1375
|
+
const store = await readTodoStore({ repoRoot });
|
|
1376
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
1377
|
+
if (member === undefined) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active');
|
|
1378
|
+
const witnessSet = await readTodoWitnessSet({ repoRoot, planKey });
|
|
1379
|
+
if (witnessSet === null) {
|
|
1380
|
+
throw new TodoStoreError('WITNESS_MIGRATION_UNAVAILABLE', 'witness_set_absent', undefined, {
|
|
1381
|
+
witness_ref: todoWitnessRef(planKey),
|
|
1382
|
+
next_action: `lattice todo independence witness scaffold --plan ${planKey} --input <draft>`,
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
const compiled = await compileTodoSplit({ repoRoot, member, proposal });
|
|
1386
|
+
const actor = mutationActor(env);
|
|
1387
|
+
const recordedAt = new Date().toISOString();
|
|
1388
|
+
// splitのmigrationは既存taskへのidentity写像だけである。宣言を純粋に移行・検査し、
|
|
1389
|
+
// 同じcanonical bytesをwitness先へ書けることまでapply前に確定する。これにより、
|
|
1390
|
+
// witness失敗をrevision適用後に返してplan/sourceだけ進んだ状態を作らない。
|
|
1391
|
+
const preparedWitness = prepareTodoSplitWitnessMigration({
|
|
1392
|
+
witnessSet, revision: compiled.revision,
|
|
1393
|
+
});
|
|
1394
|
+
const { ref: witnessRef } = await writeTodoWitnessSet({
|
|
1395
|
+
repoRoot, witnessSet: preparedWitness.witnessSet,
|
|
1396
|
+
});
|
|
1397
|
+
const witnessMigration = {
|
|
1398
|
+
schema: 'lattice.todo_witness_migrate_result.v1',
|
|
1399
|
+
project_id: store.project_id,
|
|
1400
|
+
plan_key: planKey,
|
|
1401
|
+
plan_version: compiled.revision.desired_plan.plan_version,
|
|
1402
|
+
witness_ref: witnessRef,
|
|
1403
|
+
migrated_count: preparedWitness.migrated_count,
|
|
1404
|
+
removed_count: preparedWitness.removed_count,
|
|
1405
|
+
unchanged_count: preparedWitness.unchanged_count,
|
|
1406
|
+
witness_set_digest: preparedWitness.witnessSet.witness_set_digest,
|
|
1407
|
+
result_digest: '',
|
|
1408
|
+
};
|
|
1409
|
+
witnessMigration.result_digest = todoSelfDigest(witnessMigration, 'result_digest');
|
|
1410
|
+
const receipt = compiled.revision.schema === 'lattice.phase_todo_revision.v3'
|
|
1411
|
+
? await applyPhaseTodoRevision({
|
|
1412
|
+
repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
|
|
1413
|
+
revision: compiled.revision, actor, recordedAt,
|
|
1414
|
+
})
|
|
1415
|
+
: await applyTodoRevision({
|
|
1416
|
+
repoRoot, writer: createTodoStoreWriter({ caller: 'g5-authoring' }),
|
|
1417
|
+
revision: compiled.revision, actor, recordedAt,
|
|
1418
|
+
});
|
|
1419
|
+
const result = {
|
|
1420
|
+
schema: 'lattice.todo_split_result.v1',
|
|
1421
|
+
project_id: store.project_id,
|
|
1422
|
+
plan_key: planKey,
|
|
1423
|
+
predecessor_task_id: proposal.task_id,
|
|
1424
|
+
residual_task_id: proposal.task_id,
|
|
1425
|
+
extracted_task_ids: compiled.extracted_task_ids,
|
|
1426
|
+
plan_version: compiled.revision.desired_plan.plan_version,
|
|
1427
|
+
revision_digest: compiled.revision.revision_digest,
|
|
1428
|
+
revision_receipt_digest: receipt.receipt_digest ?? receipt.result_digest,
|
|
1429
|
+
witness_migration_result_digest: witnessMigration.result_digest,
|
|
1430
|
+
result_digest: '',
|
|
1431
|
+
};
|
|
1432
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
1433
|
+
return result;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1325
1436
|
async function status({ repoRoot }) {
|
|
1326
1437
|
const store = await readTodoStore({ repoRoot });
|
|
1327
1438
|
return projectTodoStatus(store, {
|
|
@@ -1530,12 +1641,18 @@ async function independenceCompile({ repoRoot, planKey, inputRef }) {
|
|
|
1530
1641
|
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
1531
1642
|
if (!member) throw new TodoStoreError('STORE_INCONSISTENT', 'plan_not_active', undefined, { plan_key: planKey });
|
|
1532
1643
|
|
|
1644
|
+
// 前回artifactを渡して膨張の履歴を継ぐ。**例外を握り潰さない。**
|
|
1645
|
+
// `readTodoIndependenceArtifact` は「欠落だけnull・旧版はlegacy marker・壊れた記録は
|
|
1646
|
+
// INDEPENDENCE_ARTIFACT_INVALIDでtyped fail」を既に区別している。ここでcatchすると
|
|
1647
|
+
// **corrupt/permission/I-Oまで「初回」へ化けて履歴が黙って切れる**(suzune の監査で実測・room [1148])。
|
|
1648
|
+
const previousArtifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
1533
1649
|
const artifact = compileTodoIndependence({
|
|
1534
1650
|
witnessSet,
|
|
1535
1651
|
plan: member.plan,
|
|
1536
1652
|
baseSha,
|
|
1537
1653
|
compiledAt: new Date().toISOString(),
|
|
1538
1654
|
sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
|
|
1655
|
+
previousArtifact,
|
|
1539
1656
|
});
|
|
1540
1657
|
const { ref } = await writeTodoIndependenceArtifact({ repoRoot, artifact });
|
|
1541
1658
|
|
|
@@ -2218,7 +2335,35 @@ async function independenceForGantt({ repoRoot, store }) {
|
|
|
2218
2335
|
const projections = [];
|
|
2219
2336
|
for (const member of store.members) {
|
|
2220
2337
|
const planKey = member.plan.plan_key;
|
|
2221
|
-
|
|
2338
|
+
let artifact = null;
|
|
2339
|
+
let unreadableReason = null;
|
|
2340
|
+
try {
|
|
2341
|
+
artifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
2342
|
+
} catch (error) {
|
|
2343
|
+
if (!(error instanceof TodoStoreError)) throw error;
|
|
2344
|
+
unreadableReason = `${error.code}:${error.detail?.reason ?? error.message}`;
|
|
2345
|
+
}
|
|
2346
|
+
if (unreadableReason !== null) {
|
|
2347
|
+
if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
|
|
2348
|
+
const projected = projectIndependenceFrontier({
|
|
2349
|
+
artifact: null,
|
|
2350
|
+
readyTaskIds: frontier.filter((task) => task.plan_key === planKey)
|
|
2351
|
+
.map(({ task_id: taskId }) => taskId),
|
|
2352
|
+
activeTaskIds: status.active_set.filter((task) => task.plan_key === planKey)
|
|
2353
|
+
.map(({ task_id: taskId }) => taskId),
|
|
2354
|
+
plan: member.plan,
|
|
2355
|
+
currentBaseSha,
|
|
2356
|
+
changedPaths: null,
|
|
2357
|
+
});
|
|
2358
|
+
projections.push({
|
|
2359
|
+
project_id: member.plan.project_id,
|
|
2360
|
+
plan_key: planKey,
|
|
2361
|
+
coverage: 'unreadable',
|
|
2362
|
+
unreadable_reason: unreadableReason,
|
|
2363
|
+
frontier: projected.frontier,
|
|
2364
|
+
});
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2222
2367
|
if (artifact === null) continue;
|
|
2223
2368
|
// 記録があるplanが1つでもあれば鮮度の判定にHEADが要る。
|
|
2224
2369
|
if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
|
|
@@ -2238,6 +2383,7 @@ async function independenceForGantt({ repoRoot, store }) {
|
|
|
2238
2383
|
project_id: member.plan.project_id,
|
|
2239
2384
|
plan_key: planKey,
|
|
2240
2385
|
coverage: projected.coverage,
|
|
2386
|
+
unreadable_reason: null,
|
|
2241
2387
|
frontier: projected.frontier,
|
|
2242
2388
|
});
|
|
2243
2389
|
}
|
|
@@ -2253,12 +2399,29 @@ async function seamProposalsForGantt({ repoRoot, store }) {
|
|
|
2253
2399
|
const projections = [];
|
|
2254
2400
|
for (const member of store.members) {
|
|
2255
2401
|
const planKey = member.plan.plan_key;
|
|
2256
|
-
|
|
2402
|
+
let artifact = null;
|
|
2403
|
+
try {
|
|
2404
|
+
artifact = await readTodoSeamProposalArtifact({ repoRoot, store, planKey });
|
|
2405
|
+
} catch (error) {
|
|
2406
|
+
if (!(error instanceof TodoStoreError)) throw error;
|
|
2407
|
+
projections.push({
|
|
2408
|
+
project_id: member.plan.project_id,
|
|
2409
|
+
plan_key: planKey,
|
|
2410
|
+
coverage: 'superseded',
|
|
2411
|
+
unreadable_reason: `${error.code}:${error.detail?.reason ?? error.message}`,
|
|
2412
|
+
guidance: selectSeamProposalGuidance({ coverage: 'superseded' }),
|
|
2413
|
+
component_count: null,
|
|
2414
|
+
conflict_resource_count: null,
|
|
2415
|
+
components: [],
|
|
2416
|
+
});
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2257
2419
|
if (artifact === null) {
|
|
2258
2420
|
projections.push({
|
|
2259
2421
|
project_id: member.plan.project_id,
|
|
2260
2422
|
plan_key: planKey,
|
|
2261
2423
|
coverage: 'missing',
|
|
2424
|
+
unreadable_reason: null,
|
|
2262
2425
|
guidance: selectSeamProposalGuidance({ coverage: 'missing' }),
|
|
2263
2426
|
component_count: null,
|
|
2264
2427
|
conflict_resource_count: null,
|
|
@@ -2268,7 +2431,14 @@ async function seamProposalsForGantt({ repoRoot, store }) {
|
|
|
2268
2431
|
}
|
|
2269
2432
|
|
|
2270
2433
|
if (currentBaseSha === null) currentBaseSha = currentHeadSha(repoRoot);
|
|
2271
|
-
|
|
2434
|
+
let independenceArtifact = null;
|
|
2435
|
+
let unreadableReason = null;
|
|
2436
|
+
try {
|
|
2437
|
+
independenceArtifact = await readTodoIndependenceArtifact({ repoRoot, store, planKey });
|
|
2438
|
+
} catch (error) {
|
|
2439
|
+
if (!(error instanceof TodoStoreError)) throw error;
|
|
2440
|
+
unreadableReason = `${error.code}:${error.detail?.reason ?? error.message}`;
|
|
2441
|
+
}
|
|
2272
2442
|
const binding = artifact.source_binding;
|
|
2273
2443
|
const independenceMatches = independenceArtifact !== null
|
|
2274
2444
|
&& validateTodoIndependence(independenceArtifact)
|
|
@@ -2287,6 +2457,7 @@ async function seamProposalsForGantt({ repoRoot, store }) {
|
|
|
2287
2457
|
project_id: member.plan.project_id,
|
|
2288
2458
|
plan_key: planKey,
|
|
2289
2459
|
coverage,
|
|
2460
|
+
unreadable_reason: unreadableReason,
|
|
2290
2461
|
guidance: selectSeamProposalGuidance({ coverage }),
|
|
2291
2462
|
component_count: components.length,
|
|
2292
2463
|
conflict_resource_count: components
|
|
@@ -2538,6 +2709,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2538
2709
|
throw new TypeError('runTodoCli optionsが不正');
|
|
2539
2710
|
}
|
|
2540
2711
|
|
|
2712
|
+
const atomicCommit = argv.at(-1) === '--commit-store';
|
|
2713
|
+
if (atomicCommit) argv = argv.slice(0, -1);
|
|
2714
|
+
if (atomicCommit && ((argv[1] === '--schema' && argv[2] === '--json')
|
|
2715
|
+
|| (argv[0] === 'dashboard' && argv[1] === 'remove'))) {
|
|
2716
|
+
return atomicStoreCommitUnsupported(stderr, argv);
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2541
2719
|
if (argv[0] === 'migrate' && argv[1] === '--input'
|
|
2542
2720
|
&& typeof argv[2] === 'string' && path.isAbsolute(argv[2])) {
|
|
2543
2721
|
return typedArgumentFailure(stderr, 'INPUT_OUTSIDE_REPOSITORY', 'absolute_input_path_rejected', {
|
|
@@ -2731,6 +2909,10 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2731
2909
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
2732
2910
|
&& argv[3] === '--input' && isTodoRef(argv[4])) {
|
|
2733
2911
|
action = (repoRoot) => revise({ repoRoot, env, planKey: argv[2], inputRef: argv[4] });
|
|
2912
|
+
} else if (argv.length === 5 && argv[0] === 'split'
|
|
2913
|
+
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
2914
|
+
&& argv[3] === '--input' && isTodoRef(argv[4])) {
|
|
2915
|
+
action = (repoRoot) => splitTodo({ repoRoot, env, planKey: argv[2], inputRef: argv[4] });
|
|
2734
2916
|
} else if (argv.length === 3 && argv[0] === 'revise-set'
|
|
2735
2917
|
&& argv[1] === '--input' && isTodoRef(argv[2])) {
|
|
2736
2918
|
action = (repoRoot) => reviseSet({ repoRoot, env, inputRef: argv[2] });
|
|
@@ -2784,6 +2966,13 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2784
2966
|
action = (repoRoot) => startTask({ repoRoot, env, planKey: argv[2], taskId: argv[4],
|
|
2785
2967
|
overrideReason, parallelFrontier: argv.length === 6,
|
|
2786
2968
|
serialConfirmed: argv.length === 8 });
|
|
2969
|
+
} else if (argv.length === 7 && argv[0] === 'retract'
|
|
2970
|
+
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
2971
|
+
&& argv[3] === '--task' && isTodoIdentifier(argv[4])
|
|
2972
|
+
&& argv[5] === '--reason' && argv[6].length > 0) {
|
|
2973
|
+
action = (repoRoot) => retractStart({
|
|
2974
|
+
repoRoot, env, planKey: argv[2], taskId: argv[4], reason: argv[6],
|
|
2975
|
+
});
|
|
2787
2976
|
} else if (argv.length === 7 && argv[0] === 'block'
|
|
2788
2977
|
&& argv[1] === '--plan' && isTodoIdentifier(argv[2])
|
|
2789
2978
|
&& argv[3] === '--task' && isTodoIdentifier(argv[4])
|
|
@@ -2831,6 +3020,9 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2831
3020
|
command, next_action: argumentHelp,
|
|
2832
3021
|
});
|
|
2833
3022
|
}
|
|
3023
|
+
if (atomicCommit && !supportsAtomicStoreCommit(argv)) {
|
|
3024
|
+
return atomicStoreCommitUnsupported(stderr, argv);
|
|
3025
|
+
}
|
|
2834
3026
|
|
|
2835
3027
|
try {
|
|
2836
3028
|
const repoRoot = resolveRepoRoot(cwd);
|
|
@@ -2840,7 +3032,9 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2840
3032
|
if (!ganttCommand && !dashboardAdopt && !migrationDryRun) {
|
|
2841
3033
|
await ensureActiveProjectDashboard({ repoRoot, env });
|
|
2842
3034
|
}
|
|
2843
|
-
const result =
|
|
3035
|
+
const result = atomicCommit
|
|
3036
|
+
? await commitTodoStoreMutation({ repoRoot, argv, action, env })
|
|
3037
|
+
: await action(repoRoot);
|
|
2844
3038
|
if (result !== null) stdout.write(`${JSON.stringify(result)}\n`);
|
|
2845
3039
|
return 0;
|
|
2846
3040
|
} catch (error) {
|
package/src/todo-contracts.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { isCanonicalUtcTimestamp } from './timestamp-contract.mjs';
|
|
3
3
|
|
|
4
4
|
export const TODO_EVENT_KINDS = Object.freeze([
|
|
5
|
-
'plan_genesis', 'start', 'block', 'unblock', 'done', 'reopen',
|
|
5
|
+
'plan_genesis', 'start', 'start_retracted', 'block', 'unblock', 'done', 'reopen',
|
|
6
6
|
'phase_review', 'phase_accept', 'phase_reject', 'phase_reopen',
|
|
7
7
|
// ADR 0148: 監査していない歴史を「監査なしで閉じた」として明示的に閉じるための専用kind。
|
|
8
8
|
// phase_review/accept/reject/reopenと同じv3 tail event shape(phase_id持ち)に収め、
|
|
@@ -507,6 +507,9 @@ function validPayload(event) {
|
|
|
507
507
|
&& nullableText(payload.reason) && payload.reason !== null;
|
|
508
508
|
}
|
|
509
509
|
if (event.kind === 'start') return exactRecord(payload, ['override_reason']) && nullableText(payload.override_reason);
|
|
510
|
+
if (event.kind === 'start_retracted') return exactRecord(payload, ['reason', 'target_start_digest'])
|
|
511
|
+
&& nullableText(payload.reason) && payload.reason !== null
|
|
512
|
+
&& isTodoDigest(payload.target_start_digest);
|
|
510
513
|
if (event.kind === 'block') return exactRecord(payload, ['reason']) && nullableText(payload.reason) && payload.reason !== null;
|
|
511
514
|
if (event.kind === 'unblock') return exactRecord(payload, []);
|
|
512
515
|
if (event.kind === 'done' && payload?.done_mode === 'authored') {
|
|
@@ -139,7 +139,8 @@ export function renderRightPane(
|
|
|
139
139
|
) {
|
|
140
140
|
const lookup = presentationLookup(presentation);
|
|
141
141
|
const sectionByKey = new Map(sections.map((section) => [refKey(section.ref), section]));
|
|
142
|
-
const
|
|
142
|
+
const semanticNodes = [...layout.nodes, ...(layout.hierarchy_nodes ?? [])];
|
|
143
|
+
const nodeByKey = new Map(semanticNodes.map((node) => [refKey(node.ref), node]));
|
|
143
144
|
const folds = foldIndex(layout);
|
|
144
145
|
const incoming = new Map(sections.map((section) => [refKey(section.ref), []]));
|
|
145
146
|
const outgoing = new Map(sections.map((section) => [refKey(section.ref), []]));
|
|
@@ -163,7 +164,7 @@ export function renderRightPane(
|
|
|
163
164
|
const counts = { pending: 0, 'in-progress': 0, blocked: 0, done: 0 };
|
|
164
165
|
for (const section of sections) counts[section.state.status] += 1;
|
|
165
166
|
const active = sections.filter((section) => section.state.status === 'in-progress');
|
|
166
|
-
const ready =
|
|
167
|
+
const ready = semanticNodes.filter((node) => node.visibility.next_ready);
|
|
167
168
|
const independenceSummary = summarizeIndependence(layout);
|
|
168
169
|
const readyHeadline = ready.length > 1
|
|
169
170
|
? `<p class="readiness-note"><strong>同時dispatch推奨:</strong> ${ready.length}工程。${escapeHtmlText(dispatchBasis(independenceSummary))}</p>`
|
|
@@ -90,8 +90,7 @@ export function presentationLookup(presentation) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
export function taskReference(section, lookup) {
|
|
93
|
-
|
|
94
|
-
return number === undefined ? `ID ${section.task.task_id}` : `工程 ${number.display_number}`;
|
|
93
|
+
return `工程 ${section.task.task_id}`;
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
export function renderRelationList(relations, sectionByKey, lookup, emptyText, folds = new Set()) {
|
|
@@ -116,3 +116,16 @@ button.fold-chip[aria-expanded="true"]{border-color:var(--text-primary)}
|
|
|
116
116
|
.lane-dimmed{opacity:.35}
|
|
117
117
|
@media(max-width:900px){body{display:block;height:auto}.shell{display:block}.pane-divider{display:none}.gantt-pane,.narrative-pane{height:70vh}.gantt-pane{border-bottom:1px solid var(--border)}}
|
|
118
118
|
`;
|
|
119
|
+
|
|
120
|
+
// 階層を持つplanだけが読み込む。親無しplanのHTML/CSS bytesを変えないため、基底CSSへは混ぜない。
|
|
121
|
+
export const NESTED_CSS = `
|
|
122
|
+
.nested-task-panel{filter:drop-shadow(0 4px 12px rgba(11,11,11,.18))}
|
|
123
|
+
.nested-task-surface{fill:var(--surface-1);stroke:var(--text-secondary);stroke-width:1.5}
|
|
124
|
+
.nested-task-label{fill:var(--text-primary);font-size:12px;font-weight:650}
|
|
125
|
+
.nested-task-link{fill:none;stroke:var(--text-secondary);stroke-width:1.5;stroke-dasharray:4 3}
|
|
126
|
+
.nested-task-diagram{outline:1px solid var(--border);background:var(--surface-1)}
|
|
127
|
+
.nested-task-toggle{cursor:pointer}
|
|
128
|
+
.nested-task-toggle rect{fill:var(--surface-1);stroke:var(--text-secondary);stroke-width:1.5}
|
|
129
|
+
.nested-task-toggle text{fill:var(--text-primary);font-size:14px;font-weight:650}
|
|
130
|
+
.nested-task-toggle:focus rect{stroke:var(--text-primary);stroke-width:2.5}
|
|
131
|
+
`;
|
package/src/todo-gantt-html.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { serializeJsonForScript } from './todo-markdown-renderer.mjs';
|
|
|
5
5
|
import { renderTodoGanttSvg, TODO_GANTT_STATUS_PRESENTATION } from './todo-gantt-svg.mjs';
|
|
6
6
|
import { renderDiagramLegend, renderRightPane } from './todo-gantt-html-independence.mjs';
|
|
7
7
|
import { escapeHtmlAttribute, escapeHtmlText, refKey } from './todo-gantt-html-shared.mjs';
|
|
8
|
-
import { CSS } from './todo-gantt-html-style.mjs';
|
|
8
|
+
import { CSS, NESTED_CSS } from './todo-gantt-html-style.mjs';
|
|
9
9
|
|
|
10
10
|
export const TODO_GANTT_RENDERER_VERSION = 'lattice.todo_gantt_renderer.v19';
|
|
11
11
|
export const TODO_GANTT_PROSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
@@ -188,6 +188,18 @@ const CONTROLLER = `
|
|
|
188
188
|
})();
|
|
189
189
|
`;
|
|
190
190
|
|
|
191
|
+
const NESTED_CONTROLLER = `
|
|
192
|
+
(()=>{
|
|
193
|
+
const root=document.querySelector('[data-gantt-root]');if(!root)return;
|
|
194
|
+
const toggles=[...root.querySelectorAll('[data-nested-toggle-for]')];
|
|
195
|
+
const panels=[...root.querySelectorAll('[data-nested-panel-for]')];
|
|
196
|
+
const panelFor=(key)=>panels.find(panel=>panel.dataset.nestedPanelFor===key);
|
|
197
|
+
const toggle=(control)=>{const key=control.dataset.nestedToggleFor;const panel=panelFor(key);if(!panel)return;const open=panel.hasAttribute('hidden');panel.toggleAttribute('hidden',!open);const link=[...root.querySelectorAll('[data-nested-link-for]')].find(candidate=>candidate.dataset.nestedLinkFor===key);link?.toggleAttribute('hidden',!open);control.setAttribute('aria-expanded',String(open));const mark=control.querySelector('text');if(mark)mark.textContent=open?'−':'+';};
|
|
198
|
+
root.addEventListener('click',event=>{const control=event.target.closest('[data-nested-toggle-for]');if(!control||!root.contains(control))return;event.preventDefault();event.stopPropagation();toggle(control);});
|
|
199
|
+
root.addEventListener('keydown',event=>{const control=event.target.closest('[data-nested-toggle-for]');if(!control||!root.contains(control)||(event.key!=='Enter'&&event.key!==' '))return;event.preventDefault();event.stopPropagation();toggle(control);});
|
|
200
|
+
})();
|
|
201
|
+
`;
|
|
202
|
+
|
|
191
203
|
export function renderTodoGanttHtml({
|
|
192
204
|
readModel, layout, narratives = [], anchorOutcomes = [], presentation = null, metadata = {},
|
|
193
205
|
expandedLayout = null, noteContexts = null, noteWarnings = [],
|
|
@@ -208,6 +220,7 @@ export function renderTodoGanttHtml({
|
|
|
208
220
|
}
|
|
209
221
|
const normalized = normalizeSections(readModel, narratives, anchorOutcomes, noteContexts);
|
|
210
222
|
const displayName = projectDisplayName(readModel, metadata);
|
|
223
|
+
const hasHierarchy = layout?.hierarchy?.schema === 'lattice.todo_gantt_hierarchy.v1';
|
|
211
224
|
const svg = renderTodoGanttSvg(layout, { presentation });
|
|
212
225
|
// The expanded diagram travels with the page so the badge can bring the
|
|
213
226
|
// history back without a round trip. A file:// artifact has nowhere to ask.
|
|
@@ -224,7 +237,7 @@ export function renderTodoGanttHtml({
|
|
|
224
237
|
metadata,
|
|
225
238
|
presentation,
|
|
226
239
|
});
|
|
227
|
-
const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong>${renderAuditPendingChip(readModel)}<button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}</script></body></html>`;
|
|
240
|
+
const html = `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lattice — ${escapeHtmlText(displayName)} 依存工程図</title><style>${CSS}${hasHierarchy ? NESTED_CSS : ''}</style></head><body data-gantt-root data-view-state="overview"><main class="shell"><section class="gantt-pane" aria-label="${escapeHtmlAttribute(displayName)} 依存工程図"><div class="diagram-toolbar" role="group" aria-label="図のズーム"><strong class="project-heading">${escapeHtmlText(displayName)} 依存工程図</strong>${renderAuditPendingChip(readModel)}<button type="button" data-zoom-action="out" aria-label="縮小">−</button><button type="button" data-zoom-action="reset">等倍</button><button type="button" data-zoom-action="in" aria-label="拡大">+</button><button type="button" data-zoom-action="fit">全体表示</button><output class="zoom-readout" data-zoom-output aria-live="polite">100%</output><span class="diagram-note">縦=依存段階(時間ではない)</span></div>${renderDiagramLegend(presentation, layout, expandedSvg !== '')}<div class="diagram-scroll" data-diagram-scroll tabindex="0" aria-label="縦方向を主にスクロール可能な依存工程図">${diagrams}</div></section><div class="pane-divider" data-pane-divider aria-hidden="true"></div><aside class="narrative-pane" aria-label="選択工程の詳細と全工程一覧">${rightPane}</aside></main><script type="application/json" id="todo-gantt-data">${staticData}</script><script>${CONTROLLER}${hasHierarchy ? NESTED_CONTROLLER : ''}</script></body></html>`;
|
|
228
241
|
const htmlBytes = Buffer.byteLength(html, 'utf8');
|
|
229
242
|
if (htmlBytes > TODO_GANTT_HTML_MAX_BYTES) {
|
|
230
243
|
throw new TodoGanttRenderError('TODO_SCALE_EXCEEDED', 'todo gantt HTML limit exceeded', {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TODO_GANTT_SCOPES, projectTodoGanttScope } from './todo-gantt-scope.mjs';
|
|
2
|
+
import { buildTodoGanttHierarchy } from './todo-gantt-nested.mjs';
|
|
2
3
|
|
|
3
4
|
const TASK_LIMIT = 2_000;
|
|
4
5
|
const EDGE_LIMIT = 8_000;
|
|
@@ -521,7 +522,7 @@ function normalizeSeamProposals(value) {
|
|
|
521
522
|
return { summary: { plans } };
|
|
522
523
|
}
|
|
523
524
|
|
|
524
|
-
|
|
525
|
+
function layoutTodoGanttFlat(readModel, chainProjection, options = {}) {
|
|
525
526
|
const scope = options.scope ?? 'live';
|
|
526
527
|
if (!TODO_GANTT_SCOPES.includes(scope)) {
|
|
527
528
|
fail('TODO_LAYOUT_INVALID_INPUT', `scope must be one of ${TODO_GANTT_SCOPES.join(', ')}`);
|
|
@@ -898,3 +899,72 @@ export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
|
898
899
|
},
|
|
899
900
|
};
|
|
900
901
|
}
|
|
902
|
+
|
|
903
|
+
function applyHierarchySemantics(level, semanticByKey) {
|
|
904
|
+
const layout = {
|
|
905
|
+
...level.layout,
|
|
906
|
+
nodes: level.layout.nodes.map((node) => {
|
|
907
|
+
const semantic = semanticByKey.get(refKey(node.ref));
|
|
908
|
+
return semantic === undefined ? node : { ...node, visibility: { ...semantic.visibility } };
|
|
909
|
+
}),
|
|
910
|
+
};
|
|
911
|
+
return {
|
|
912
|
+
layout,
|
|
913
|
+
children: level.children.map((child) => ({
|
|
914
|
+
...child,
|
|
915
|
+
level: applyHierarchySemantics(child.level, semanticByKey),
|
|
916
|
+
})),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function descendantNodes(level) {
|
|
921
|
+
return level.children.flatMap((child) => [
|
|
922
|
+
...child.level.layout.nodes,
|
|
923
|
+
...descendantNodes(child.level),
|
|
924
|
+
]);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
export function layoutTodoGantt(readModel, chainProjection, options = {}) {
|
|
928
|
+
const fullLayout = layoutTodoGanttFlat(readModel, chainProjection, options);
|
|
929
|
+
let nested;
|
|
930
|
+
try {
|
|
931
|
+
const visibleTaskKeys = (options.scope ?? 'live') === 'all' ? null
|
|
932
|
+
: new Set(fullLayout.nodes.map((node) => refKey(node.ref)));
|
|
933
|
+
nested = buildTodoGanttHierarchy(
|
|
934
|
+
readModel, options, layoutTodoGanttFlat, visibleTaskKeys,
|
|
935
|
+
);
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (error?.code !== 'TODO_LAYOUT_INVALID_HIERARCHY') throw error;
|
|
938
|
+
fail(error.code, error.message, error.detail);
|
|
939
|
+
}
|
|
940
|
+
if (nested === null) return fullLayout;
|
|
941
|
+
|
|
942
|
+
// 階層ごとの縮約graphは座標だけを決める。ready/最長鎖/独立性まで縮約graphで
|
|
943
|
+
// 再計算すると、未完の子を持つdone親が後続をreadyへ進めるなど、実graphと違う判断を描く。
|
|
944
|
+
const semanticLayout = options.scope === 'all'
|
|
945
|
+
? fullLayout : layoutTodoGanttFlat(readModel, chainProjection, { ...options, scope: 'all' });
|
|
946
|
+
const semanticByKey = new Map(semanticLayout.nodes.map((node) => [refKey(node.ref), node]));
|
|
947
|
+
const semanticRoot = applyHierarchySemantics(nested.root, semanticByKey);
|
|
948
|
+
const rootLayout = semanticRoot.layout;
|
|
949
|
+
return {
|
|
950
|
+
...rootLayout,
|
|
951
|
+
full_edges: fullLayout.full_edges,
|
|
952
|
+
groups: fullLayout.groups,
|
|
953
|
+
scope: fullLayout.scope,
|
|
954
|
+
folded: fullLayout.folded,
|
|
955
|
+
hierarchy_nodes: descendantNodes(semanticRoot),
|
|
956
|
+
metrics: {
|
|
957
|
+
...rootLayout.metrics,
|
|
958
|
+
visible_node_count: nested.metrics.visibleNodeCount,
|
|
959
|
+
visible_edge_count: nested.metrics.visibleEdgeCount,
|
|
960
|
+
task_count: fullLayout.metrics.task_count,
|
|
961
|
+
edge_count: fullLayout.metrics.edge_count,
|
|
962
|
+
},
|
|
963
|
+
hierarchy: {
|
|
964
|
+
schema: 'lattice.todo_gantt_hierarchy.v1',
|
|
965
|
+
children: semanticRoot.children,
|
|
966
|
+
maximum_depth: nested.metrics.maximumDepth,
|
|
967
|
+
task_count: nested.metrics.taskCount,
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
}
|
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
|
|
|
@@ -58,7 +61,7 @@ function withExternalPane(html, pane) {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
function liveHtml(html, headDigest, eventsPath, externalPane = null) {
|
|
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);
|
|
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>`;
|
|
62
65
|
const publicMetadata = '<meta name="description" content="Latticeで管理しているプロジェクトの依存工程と進捗を確認できます。"><meta name="robots" content="noindex, nofollow"><meta name="theme-color" content="#f7f3ea">';
|
|
63
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>';
|
|
64
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>';
|
|
@@ -184,6 +187,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
184
187
|
|| typeof registry.list !== 'function') throw new TypeError('registry required');
|
|
185
188
|
const clientsByProject = new Map();
|
|
186
189
|
const lastHeads = new Map();
|
|
190
|
+
const lastHeartbeats = new Map();
|
|
187
191
|
let eventId = 0;
|
|
188
192
|
let checking = false;
|
|
189
193
|
let closed = false;
|
|
@@ -194,6 +198,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
194
198
|
for (const client of clients) client.end();
|
|
195
199
|
clientsByProject.delete(projectId);
|
|
196
200
|
lastHeads.delete(projectId);
|
|
201
|
+
lastHeartbeats.delete(projectId);
|
|
197
202
|
}
|
|
198
203
|
};
|
|
199
204
|
const unsubscribe = typeof registry.subscribe === 'function'
|
|
@@ -255,6 +260,7 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
255
260
|
try {
|
|
256
261
|
const head = await project.readHead();
|
|
257
262
|
lastHeads.set(project.projectId, head);
|
|
263
|
+
if (!lastHeartbeats.has(project.projectId)) lastHeartbeats.set(project.projectId, Date.now());
|
|
258
264
|
sendEvent(response, 'state', ++eventId, { head_digest: head });
|
|
259
265
|
} catch (error) {
|
|
260
266
|
sendEvent(response, 'lattice-error', ++eventId, { code: error?.code ?? 'STORE_READ_FAILED' });
|
|
@@ -301,6 +307,11 @@ export async function startTodoGanttDashboardServer({ registry, port = 0, redire
|
|
|
301
307
|
lastHeads.set(project.projectId, head);
|
|
302
308
|
for (const client of clients) sendEvent(client, 'state', ++eventId, { head_digest: head });
|
|
303
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
|
+
}
|
|
304
315
|
} catch (error) {
|
|
305
316
|
for (const client of clients) sendEvent(client, 'lattice-error', ++eventId,
|
|
306
317
|
{ code: error?.code ?? 'STORE_READ_FAILED' });
|