@quolu/lattice 0.64.1 → 0.64.3
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/package.json +1 -1
- package/src/cli-help.mjs +2 -0
- package/src/project-cli.mjs +8 -1
- package/src/todo-cli.mjs +20 -2
- package/src/todo-store-worktree-eol.mjs +220 -0
- package/src/todo-store.mjs +35 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.64.
|
|
3
|
+
"version": "0.64.3",
|
|
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
|
@@ -86,6 +86,7 @@ Read commands:
|
|
|
86
86
|
phase status --plan <key>
|
|
87
87
|
|
|
88
88
|
Write commands:
|
|
89
|
+
repair-eol --json # Git checkoutでCRLF化された既存storeをcanonical LFへ戻し、EOL保護を追加する
|
|
89
90
|
dashboard adopt --json # 衝突したproject_idの配信元rootを現在repoへ明示的に移す
|
|
90
91
|
dashboard remove <project_id> --json # 登録簿から1件外す(対象repoの外からも叩ける)
|
|
91
92
|
note --plan <key> [--task <id>] (--message <text>|--input <file>)
|
|
@@ -251,6 +252,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
251
252
|
'todo seam-profile': 'todo seam-profile --plan <key> --file <path> [--json]',
|
|
252
253
|
'todo seam-proposal': 'todo seam-proposal [--plan <key>] [--json] | compile --plan <key>',
|
|
253
254
|
'todo verify': 'todo verify [--plan <key>] [--json]',
|
|
255
|
+
'todo repair-eol': 'todo repair-eol --json',
|
|
254
256
|
'todo snapshot': 'todo snapshot --rebuild --plan <key>',
|
|
255
257
|
'todo gantt': 'todo gantt serve --port <port> [--scope live|all] # 動的表示のみ。静的HTML生成は廃止',
|
|
256
258
|
'todo gantt serve': 'todo gantt serve --port <0..65535> [--scope live|all] # loopback動的viewer',
|
package/src/project-cli.mjs
CHANGED
|
@@ -325,7 +325,14 @@ async function resolveProjectState({ cwd, cliVersion, diagnoseStructureArtifacts
|
|
|
325
325
|
if (error?.code === 'STRUCTURE_ARTIFACT_INVALID') throw error;
|
|
326
326
|
const reason = error instanceof TodoStoreError
|
|
327
327
|
? `${error.code}:${error.detail?.reason ?? error.message}` : 'store_validation_failed';
|
|
328
|
-
|
|
328
|
+
const nextAction = error instanceof TodoStoreError
|
|
329
|
+
&& error.detail?.next_action === 'lattice todo repair-eol --json'
|
|
330
|
+
? { command: error.detail.next_action, reason: `${reason}:${error.detail.ref}` }
|
|
331
|
+
: null;
|
|
332
|
+
return {
|
|
333
|
+
exitCode: 1, repoRoot, store: null, todo: null,
|
|
334
|
+
result: invalidStatus({ cliVersion, repoRoot, reason, nextAction }),
|
|
335
|
+
};
|
|
329
336
|
}
|
|
330
337
|
}
|
|
331
338
|
|
package/src/todo-cli.mjs
CHANGED
|
@@ -177,6 +177,7 @@ import {
|
|
|
177
177
|
} from './todo-note-store.mjs';
|
|
178
178
|
import { readTodoParallelCandidatesForStatus } from './todo-parallel-candidates.mjs';
|
|
179
179
|
import { commitTodoStoreMutation } from './todo-store-git-transaction.mjs';
|
|
180
|
+
import { repairTodoStoreWorktreeEol } from './todo-store-worktree-eol.mjs';
|
|
180
181
|
|
|
181
182
|
const CLI_ERROR_SCHEMA = 'lattice.cli_error.v2';
|
|
182
183
|
const DEFAULT_GANTT_SCOPE = 'live';
|
|
@@ -254,7 +255,7 @@ function internalFailure(stderr, error) {
|
|
|
254
255
|
|
|
255
256
|
const TODO_COMMAND_NAMES = Object.freeze([
|
|
256
257
|
'status', 'show', 'note', 'bindings', 'independence', 'structure', 'seam-profile', 'seam-proposal',
|
|
257
|
-
'verify', 'snapshot', 'gantt', 'dashboard', 'phase', 'migrate', 'start', 'block',
|
|
258
|
+
'verify', 'repair-eol', 'snapshot', 'gantt', 'dashboard', 'phase', 'migrate', 'start', 'block',
|
|
258
259
|
'unblock', 'done', 'reopen', 'evidence', 'split', 'revise', 'revise-phase', 'revise-set',
|
|
259
260
|
]);
|
|
260
261
|
|
|
@@ -3579,6 +3580,20 @@ async function rebuildSnapshot({ repoRoot, planKey }) {
|
|
|
3579
3580
|
return result;
|
|
3580
3581
|
}
|
|
3581
3582
|
|
|
3583
|
+
async function repairStoreEol({ repoRoot }) {
|
|
3584
|
+
const repair = await repairTodoStoreWorktreeEol({ repoRoot });
|
|
3585
|
+
const store = await readTodoStore({ repoRoot });
|
|
3586
|
+
const result = {
|
|
3587
|
+
schema: 'lattice.todo_eol_repair_result.v1',
|
|
3588
|
+
project_id: store.project_id,
|
|
3589
|
+
...repair,
|
|
3590
|
+
repaired_count: repair.repaired_refs.length,
|
|
3591
|
+
result_digest: '',
|
|
3592
|
+
};
|
|
3593
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
3594
|
+
return result;
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3582
3597
|
/**
|
|
3583
3598
|
* `lattice todo` namespace. Exact position, order, and argument count are part of
|
|
3584
3599
|
* the public contract; usage failures never use a JSON envelope.
|
|
@@ -3633,7 +3648,10 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
3633
3648
|
|
|
3634
3649
|
const automatedStructureRealize = parseAutomatedStructureRealizeArgs(argv);
|
|
3635
3650
|
let action = null;
|
|
3636
|
-
if (
|
|
3651
|
+
if (argv.length === 2 && argv[0] === 'repair-eol' && argv[1] === '--json') {
|
|
3652
|
+
// 壊れたstoreを読めない時の入口なので、dashboard所有権pre-hookを通さない。
|
|
3653
|
+
action = (repoRoot) => repairStoreEol({ repoRoot });
|
|
3654
|
+
} else if ((argv.length === 1 && argv[0] === 'status')
|
|
3637
3655
|
|| (argv.length === 2 && argv[0] === 'status' && argv[1] === '--json')) {
|
|
3638
3656
|
action = (repoRoot) => status({ repoRoot });
|
|
3639
3657
|
} else if (argv.length === 3 && argv[0] === 'dashboard'
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
lstat, readFile, readdir, rename, rm, writeFile,
|
|
4
|
+
} from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { gitCatFileBatch, gitSpawnSync } from './git-process.mjs';
|
|
8
|
+
import { TodoStoreError } from './todo-store.mjs';
|
|
9
|
+
|
|
10
|
+
const STORE_REF = '.lattice/todo';
|
|
11
|
+
const ATTRIBUTES_REF = '.lattice/.gitattributes';
|
|
12
|
+
const EOL_PROTECTION = '# Lattice store artifacts are canonical JSON+LF bytes; EOL conversion corrupts the store.\n* -text\n';
|
|
13
|
+
const INDEX_REFRESH_BATCH_SIZE = 40;
|
|
14
|
+
|
|
15
|
+
function fail(reason, detail = {}) {
|
|
16
|
+
throw new TodoStoreError('STORE_EOL_REPAIR_UNSAFE', reason, undefined, detail);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function regularFileState(absolute, ref) {
|
|
20
|
+
let stats;
|
|
21
|
+
try { stats = await lstat(absolute); } catch (error) {
|
|
22
|
+
if (error?.code === 'ENOENT') return null;
|
|
23
|
+
fail('artifact_unreadable', { ref, message: error.message });
|
|
24
|
+
}
|
|
25
|
+
if (stats.isSymbolicLink() || !stats.isFile()) fail('unsafe_artifact_path', { ref });
|
|
26
|
+
return stats;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function assertSafeDirectoryChain(repoRoot, relative) {
|
|
30
|
+
let absolute = repoRoot;
|
|
31
|
+
let ref = '';
|
|
32
|
+
for (const segment of relative.replaceAll('\\', '/').split('/')) {
|
|
33
|
+
ref = path.posix.join(ref, segment);
|
|
34
|
+
absolute = path.join(absolute, segment);
|
|
35
|
+
let stats;
|
|
36
|
+
try { stats = await lstat(absolute); } catch (error) {
|
|
37
|
+
fail(error?.code === 'ENOENT' ? 'store_missing' : 'store_unreadable', {
|
|
38
|
+
ref, message: error.message,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) fail('unsafe_artifact_path', { ref });
|
|
42
|
+
}
|
|
43
|
+
return absolute;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function safeRegularFileState(repoRoot, ref) {
|
|
47
|
+
await assertSafeDirectoryChain(repoRoot, path.posix.dirname(ref));
|
|
48
|
+
return regularFileState(path.join(repoRoot, ref), ref);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function collectArtifactRefs(repoRoot, relative = STORE_REF) {
|
|
52
|
+
const absolute = await assertSafeDirectoryChain(repoRoot, relative);
|
|
53
|
+
let entries;
|
|
54
|
+
try { entries = await readdir(absolute, { withFileTypes: true }); } catch (error) {
|
|
55
|
+
fail(error?.code === 'ENOENT' ? 'store_missing' : 'store_unreadable', {
|
|
56
|
+
ref: relative, message: error.message,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const refs = [];
|
|
60
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
61
|
+
const ref = path.posix.join(relative.replaceAll('\\', '/'), entry.name);
|
|
62
|
+
if (entry.isSymbolicLink()) fail('unsafe_artifact_path', { ref });
|
|
63
|
+
if (entry.isDirectory()) refs.push(...await collectArtifactRefs(repoRoot, ref));
|
|
64
|
+
else if (entry.isFile() && /\.(?:json|jsonl)$/u.test(entry.name)) refs.push(ref);
|
|
65
|
+
else if (!entry.isFile()) fail('unsafe_artifact_path', { ref });
|
|
66
|
+
}
|
|
67
|
+
return refs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizePureCrlf(bytes, ref) {
|
|
71
|
+
if (!bytes.includes(13)) return null;
|
|
72
|
+
const normalized = [];
|
|
73
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
74
|
+
const byte = bytes[index];
|
|
75
|
+
if (byte !== 13) {
|
|
76
|
+
normalized.push(byte);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (bytes[index + 1] !== 10) fail('artifact_not_pure_crlf_conversion', { ref });
|
|
80
|
+
}
|
|
81
|
+
return Buffer.from(normalized);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function atomicReplace({ repoRoot, ref, absolute, sourceBytes, bytes, mode }) {
|
|
85
|
+
await safeRegularFileState(repoRoot, ref);
|
|
86
|
+
const currentBytes = await readFile(absolute);
|
|
87
|
+
if (!currentBytes.equals(sourceBytes)) fail('artifact_changed_during_repair', { ref });
|
|
88
|
+
const temporary = `${absolute}.eol-repair-${process.pid}-${randomBytes(6).toString('hex')}.tmp`;
|
|
89
|
+
try {
|
|
90
|
+
await writeFile(temporary, bytes, { flag: 'wx', mode: mode & 0o777 });
|
|
91
|
+
await rename(temporary, absolute);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
try { await rm(temporary, { force: true }); } catch { /* noop */ }
|
|
94
|
+
fail('artifact_write_failed', { ref: path.basename(absolute), message: error.message });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function prepareProtection(repoRoot) {
|
|
99
|
+
const absolute = path.join(repoRoot, ATTRIBUTES_REF);
|
|
100
|
+
const stats = await safeRegularFileState(repoRoot, ATTRIBUTES_REF);
|
|
101
|
+
if (stats === null) return { absolute, create: true };
|
|
102
|
+
const text = await readFile(absolute, 'utf8');
|
|
103
|
+
if (!text.split(/\r?\n/u).includes('* -text')) {
|
|
104
|
+
fail('eol_protection_conflict', { ref: ATTRIBUTES_REF, required_rule: '* -text' });
|
|
105
|
+
}
|
|
106
|
+
return { absolute, create: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stagedArtifactRefs(repoRoot, refs) {
|
|
110
|
+
const staged = new Set();
|
|
111
|
+
for (let offset = 0; offset < refs.length; offset += INDEX_REFRESH_BATCH_SIZE) {
|
|
112
|
+
const batch = refs.slice(offset, offset + INDEX_REFRESH_BATCH_SIZE);
|
|
113
|
+
const result = gitSpawnSync(['diff-index', '--cached', '--name-only', '-z', 'HEAD', '--', ...batch], {
|
|
114
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
115
|
+
});
|
|
116
|
+
if (result.error || result.signal || result.status !== 0) {
|
|
117
|
+
fail('git_index_artifacts_unreadable', { refs: batch, status: result.status ?? null });
|
|
118
|
+
}
|
|
119
|
+
for (const ref of (result.stdout ?? '').split('\0')) {
|
|
120
|
+
if (ref !== '') staged.add(ref.replaceAll('\\', '/'));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return staged;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function refreshGitIndex(repoRoot, refs) {
|
|
127
|
+
for (let offset = 0; offset < refs.length; offset += INDEX_REFRESH_BATCH_SIZE) {
|
|
128
|
+
const batch = refs.slice(offset, offset + INDEX_REFRESH_BATCH_SIZE);
|
|
129
|
+
const refresh = gitSpawnSync(['update-index', '--refresh', '--', ...batch], {
|
|
130
|
+
cwd: repoRoot, stdio: 'ignore',
|
|
131
|
+
});
|
|
132
|
+
// 対象は呼出前にindex blob=HEADを実証済み。update-index --refreshで内容をstageせずstatだけを揃える。
|
|
133
|
+
if (refresh.error || refresh.signal || ![0, 1].includes(refresh.status)) {
|
|
134
|
+
fail('git_index_refresh_failed', { refs: batch, status: refresh.status ?? null });
|
|
135
|
+
}
|
|
136
|
+
const verified = gitSpawnSync(['diff-files', '--quiet', '--', ...batch], {
|
|
137
|
+
cwd: repoRoot, stdio: 'ignore',
|
|
138
|
+
});
|
|
139
|
+
if (verified.error || verified.signal || verified.status !== 0) {
|
|
140
|
+
fail('git_index_refresh_failed', { refs: batch, status: verified.status ?? null });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Gitのcheckoutがcanonical LF artifactをCRLFへ変換した既存storeだけを修復する。
|
|
147
|
+
* JSONの意味・順序・空白には触れず、CRLF除去後にcanonical byte列へ完全一致するfileだけを書く。
|
|
148
|
+
*/
|
|
149
|
+
export async function repairTodoStoreWorktreeEol({ repoRoot }) {
|
|
150
|
+
const protection = await prepareProtection(repoRoot);
|
|
151
|
+
const refs = await collectArtifactRefs(repoRoot);
|
|
152
|
+
const stagedRefs = stagedArtifactRefs(repoRoot, refs);
|
|
153
|
+
const artifacts = [];
|
|
154
|
+
for (const ref of refs) {
|
|
155
|
+
const absolute = path.join(repoRoot, ref);
|
|
156
|
+
const stats = await safeRegularFileState(repoRoot, ref);
|
|
157
|
+
const bytes = await readFile(absolute);
|
|
158
|
+
const normalized = normalizePureCrlf(bytes, ref);
|
|
159
|
+
artifacts.push({
|
|
160
|
+
ref, absolute, sourceBytes: bytes, bytes: normalized ?? bytes, mode: stats.mode,
|
|
161
|
+
converted: normalized !== null,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let headArtifacts = [];
|
|
166
|
+
let indexArtifacts = [];
|
|
167
|
+
if (artifacts.length > 0) {
|
|
168
|
+
const maxBodyBytes = Math.max(...artifacts.map(({ bytes }) => bytes.length));
|
|
169
|
+
try {
|
|
170
|
+
headArtifacts = gitCatFileBatch(artifacts.map(({ ref }) => `HEAD:${ref}`), {
|
|
171
|
+
cwd: repoRoot, maxBodyBytes,
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
fail('git_head_artifacts_unreadable', { message: error.message });
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
indexArtifacts = gitCatFileBatch(artifacts.map(({ ref }) => `:${ref}`), {
|
|
178
|
+
cwd: repoRoot, maxBodyBytes,
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
fail('git_index_artifacts_unreadable', { message: error.message });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const converted = [];
|
|
185
|
+
const refreshRefs = [];
|
|
186
|
+
for (const [index, artifact] of artifacts.entries()) {
|
|
187
|
+
const head = headArtifacts[index];
|
|
188
|
+
const staged = indexArtifacts[index];
|
|
189
|
+
if (artifact.converted) {
|
|
190
|
+
if (head?.type !== 'blob' || !head.bytes.equals(artifact.bytes)) {
|
|
191
|
+
fail('artifact_not_pure_checkout_conversion', { ref: artifact.ref });
|
|
192
|
+
}
|
|
193
|
+
if (stagedRefs.has(artifact.ref)
|
|
194
|
+
|| staged?.type !== 'blob' || !staged.bytes.equals(head.bytes)) {
|
|
195
|
+
fail('artifact_staged_change_present', { ref: artifact.ref });
|
|
196
|
+
}
|
|
197
|
+
converted.push(artifact);
|
|
198
|
+
}
|
|
199
|
+
if (!stagedRefs.has(artifact.ref) && head?.type === 'blob' && staged?.type === 'blob'
|
|
200
|
+
&& staged.bytes.equals(head.bytes) && artifact.bytes.equals(head.bytes)) {
|
|
201
|
+
refreshRefs.push(artifact.ref);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const artifact of converted) {
|
|
206
|
+
await atomicReplace({ repoRoot, ...artifact });
|
|
207
|
+
}
|
|
208
|
+
// 0.64.2がLF書換後のstat refreshで止まった部分状態も、再実行だけで回復させる。
|
|
209
|
+
refreshGitIndex(repoRoot, refreshRefs);
|
|
210
|
+
if (protection.create) {
|
|
211
|
+
await assertSafeDirectoryChain(repoRoot, path.posix.dirname(ATTRIBUTES_REF));
|
|
212
|
+
await writeFile(protection.absolute, EOL_PROTECTION, { flag: 'wx' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
repaired_refs: converted.map(({ ref }) => ref),
|
|
217
|
+
protection_ref: ATTRIBUTES_REF,
|
|
218
|
+
protection_created: protection.create,
|
|
219
|
+
};
|
|
220
|
+
}
|
package/src/todo-store.mjs
CHANGED
|
@@ -135,18 +135,26 @@ function decodeUtf8(bytes, code, reason) {
|
|
|
135
135
|
catch { fail(code, reason); }
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
function parseCanonicalJsonLine(bytes, { code, reason, maxBytes, validate }) {
|
|
139
|
-
|
|
138
|
+
function parseCanonicalJsonLine(bytes, { code, reason, maxBytes, validate, ref = null }) {
|
|
139
|
+
const detail = ref === null ? undefined : { ref };
|
|
140
|
+
if (bytes.length === 0 || bytes.length > maxBytes) {
|
|
141
|
+
fail(code, bytes.length > maxBytes ? 'size_limit_exceeded' : reason, detail);
|
|
142
|
+
}
|
|
140
143
|
const text = decodeUtf8(bytes, code, 'invalid_utf8');
|
|
141
|
-
if (text.includes('\r')
|
|
144
|
+
if (text.includes('\r')) {
|
|
145
|
+
fail(code, 'artifact_eol_converted', {
|
|
146
|
+
...detail, next_action: 'lattice todo repair-eol --json',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (text.startsWith('\uFEFF')) fail(code, reason, detail);
|
|
142
150
|
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
143
151
|
let value;
|
|
144
|
-
try { value = JSON.parse(body); } catch { fail(code, reason); }
|
|
145
|
-
if (!text.endsWith('\n')) fail(code, reason);
|
|
152
|
+
try { value = JSON.parse(body); } catch { fail(code, reason, detail); }
|
|
153
|
+
if (!text.endsWith('\n')) fail(code, reason, detail);
|
|
146
154
|
let expected;
|
|
147
|
-
try { expected = `${canonicalizeTodoArtifact(value)}\n`; } catch { fail(code, reason); }
|
|
148
|
-
if (text !== expected) fail(code, 'non_canonical_or_duplicate_key');
|
|
149
|
-
if (!validate(value)) fail(code, 'schema_invalid');
|
|
155
|
+
try { expected = `${canonicalizeTodoArtifact(value)}\n`; } catch { fail(code, reason, detail); }
|
|
156
|
+
if (text !== expected) fail(code, 'non_canonical_or_duplicate_key', detail);
|
|
157
|
+
if (!validate(value)) fail(code, 'schema_invalid', detail);
|
|
150
158
|
return value;
|
|
151
159
|
}
|
|
152
160
|
|
|
@@ -154,7 +162,9 @@ async function readArtifact(repoRoot, ref, { code, maxBytes, validate, missing =
|
|
|
154
162
|
const state = await pathState(repoRoot, ref, code, { missing });
|
|
155
163
|
if (state === null) return null;
|
|
156
164
|
const bytes = await readFile(state.absolute);
|
|
157
|
-
return parseCanonicalJsonLine(bytes, {
|
|
165
|
+
return parseCanonicalJsonLine(bytes, {
|
|
166
|
+
code, reason: 'artifact_truncated_or_trailing_bytes', maxBytes, validate, ref,
|
|
167
|
+
});
|
|
158
168
|
}
|
|
159
169
|
|
|
160
170
|
async function readSnapshotArtifact(repoRoot, ref) {
|
|
@@ -163,16 +173,23 @@ async function readSnapshotArtifact(repoRoot, ref) {
|
|
|
163
173
|
const bytes = await readFile(state.absolute);
|
|
164
174
|
return parseCanonicalJsonLine(bytes, {
|
|
165
175
|
code: 'SNAPSHOT_INVALID', reason: 'snapshot_truncated_or_trailing_bytes',
|
|
166
|
-
maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoSnapshot,
|
|
176
|
+
maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoSnapshot, ref,
|
|
167
177
|
});
|
|
168
178
|
}
|
|
169
179
|
|
|
170
|
-
function parseJournalSegment(bytes) {
|
|
180
|
+
function parseJournalSegment(bytes, ref = null) {
|
|
181
|
+
const detail = ref === null ? undefined : { ref };
|
|
171
182
|
if (bytes.length === 0 || bytes.length > TODO_LIMITS.journalSegmentBytes) {
|
|
172
|
-
fail('STORE_CORRUPT', bytes.length > TODO_LIMITS.journalSegmentBytes
|
|
183
|
+
fail('STORE_CORRUPT', bytes.length > TODO_LIMITS.journalSegmentBytes
|
|
184
|
+
? 'journal_segment_limit_exceeded' : 'journal_empty', detail);
|
|
173
185
|
}
|
|
174
186
|
const text = decodeUtf8(bytes, 'STORE_CORRUPT', 'journal_invalid_utf8');
|
|
175
|
-
if (
|
|
187
|
+
if (text.includes('\r')) {
|
|
188
|
+
fail('STORE_CORRUPT', 'artifact_eol_converted', {
|
|
189
|
+
...detail, next_action: 'lattice todo repair-eol --json',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (!text.endsWith('\n') || text.startsWith('\uFEFF')) fail('STORE_CORRUPT', 'journal_byte_contract', detail);
|
|
176
193
|
const lines = text.slice(0, -1).split('\n');
|
|
177
194
|
if (lines.some((line) => line.length === 0)) fail('STORE_CORRUPT', 'journal_truncated_or_empty_line');
|
|
178
195
|
return lines.map((line) => {
|
|
@@ -205,8 +222,8 @@ async function readJournal(repoRoot, journalRef) {
|
|
|
205
222
|
const [, startText, endText, previousDigest, segmentDigest] = name.match(
|
|
206
223
|
/^(\d{12})-(\d{12})-([0-9a-f]{64})-([0-9a-f]{64})\.jsonl$/u,
|
|
207
224
|
);
|
|
225
|
+
const events = parseJournalSegment(bytes, ref);
|
|
208
226
|
if (sha256Bytes(bytes) !== segmentDigest) fail('STORE_CORRUPT', 'sealed_segment_digest_mismatch');
|
|
209
|
-
const events = parseJournalSegment(bytes);
|
|
210
227
|
if (events[0].sequence !== Number(startText) || events.at(-1).sequence !== Number(endText)) {
|
|
211
228
|
fail('STORE_CORRUPT', 'sealed_segment_range_mismatch');
|
|
212
229
|
}
|
|
@@ -220,7 +237,7 @@ async function readJournal(repoRoot, journalRef) {
|
|
|
220
237
|
if (error?.code !== 'ENOENT') fail('STORE_CORRUPT', 'sealed_segment_read_failed');
|
|
221
238
|
}
|
|
222
239
|
const activeBytes = await readFile(state.absolute);
|
|
223
|
-
segments.push({ ref: journalRef, bytes: activeBytes, events: parseJournalSegment(activeBytes) });
|
|
240
|
+
segments.push({ ref: journalRef, bytes: activeBytes, events: parseJournalSegment(activeBytes, journalRef) });
|
|
224
241
|
const events = segments.flatMap(({ events: entries }) => entries);
|
|
225
242
|
const failures = verifyLinearHashChain({
|
|
226
243
|
entries: events,
|
|
@@ -291,7 +308,7 @@ async function readPlanScopedJournal(repoRoot, journalRef) {
|
|
|
291
308
|
if (error?.code === 'ENOENT') return { ref, events: [], activeBytes: Buffer.alloc(0) };
|
|
292
309
|
fail('STORE_CORRUPT', 'plan_scoped_journal_read_failed');
|
|
293
310
|
}
|
|
294
|
-
const events = parseJournalSegment(bytes);
|
|
311
|
+
const events = parseJournalSegment(bytes, ref);
|
|
295
312
|
if (!events.every(({ kind }) => TODO_PLAN_SCOPED_EVENT_KINDS.includes(kind))) {
|
|
296
313
|
fail('STORE_CORRUPT', 'plan_scoped_journal_kind_invalid');
|
|
297
314
|
}
|
|
@@ -1571,7 +1588,8 @@ export async function readTodoStore(options = {}) {
|
|
|
1571
1588
|
snapshotStale = snapshot === null || canonicalizeTodoArtifact(snapshot) !== canonicalizeTodoArtifact(expectedSnapshot);
|
|
1572
1589
|
} catch (error) {
|
|
1573
1590
|
if (error instanceof TodoStoreError && error.code === 'SNAPSHOT_INVALID'
|
|
1574
|
-
&& !['unsafe_artifact_path', 'path_alias_or_escape', 'path_outside_store'
|
|
1591
|
+
&& !['unsafe_artifact_path', 'path_alias_or_escape', 'path_outside_store',
|
|
1592
|
+
'artifact_eol_converted'].includes(error.detail.reason)) snapshotStale = true;
|
|
1575
1593
|
else throw error;
|
|
1576
1594
|
}
|
|
1577
1595
|
if (options.forWrite === true && snapshotStale) fail('STORE_WRITE_REFUSED', 'snapshot_stale');
|