@quolu/lattice 0.50.1 → 0.52.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 +33 -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 +273 -7
- package/src/todo-contracts.mjs +19 -2
- 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 +75 -1
- package/src/todo-gantt-nested.mjs +263 -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-status.mjs +10 -1
- package/src/todo-store-git-transaction.mjs +418 -0
- package/src/todo-store.mjs +144 -4
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { constants as fsConstants } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
cp, mkdir, mkdtemp, open, readFile, rename, rm, writeFile,
|
|
6
|
+
} from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
import { selfDigest } from './runtime-contracts.mjs';
|
|
11
|
+
import {
|
|
12
|
+
RuntimeLifecycleLockError,
|
|
13
|
+
acquireRuntimeLifecycleLock,
|
|
14
|
+
} from './runtime-lifecycle-lock.mjs';
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
const STORE_REF = '.lattice/todo';
|
|
18
|
+
const LOCK_NAME = 'lattice-todo-store-commit.lock';
|
|
19
|
+
|
|
20
|
+
export class TodoStoreGitTransactionError extends Error {
|
|
21
|
+
constructor(code, message, detail = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'TodoStoreGitTransactionError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.detail = detail;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fail(code, message, detail = {}) {
|
|
30
|
+
throw new TodoStoreGitTransactionError(code, message, detail);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function git(repoRoot, args, { env = process.env, allowExitCodes = [0] } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
const result = await execFileAsync('git', args, {
|
|
36
|
+
cwd: repoRoot,
|
|
37
|
+
env,
|
|
38
|
+
encoding: 'buffer',
|
|
39
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
40
|
+
});
|
|
41
|
+
return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const exitCode = Number.isInteger(error?.code) ? error.code : null;
|
|
44
|
+
if (exitCode !== null && allowExitCodes.includes(exitCode)) {
|
|
45
|
+
return {
|
|
46
|
+
stdout: Buffer.isBuffer(error.stdout) ? error.stdout : Buffer.from(error.stdout ?? ''),
|
|
47
|
+
stderr: Buffer.isBuffer(error.stderr) ? error.stderr : Buffer.from(error.stderr ?? ''),
|
|
48
|
+
exitCode,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function text(buffer) { return buffer.toString('utf8').trim(); }
|
|
56
|
+
function fields(buffer) {
|
|
57
|
+
return buffer.toString('utf8').split('\0').filter((value) => value.length > 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function failureSummary(error) {
|
|
61
|
+
return {
|
|
62
|
+
code: typeof error?.code === 'string' ? error.code : null,
|
|
63
|
+
type: error?.constructor?.name ?? 'Error',
|
|
64
|
+
message: typeof error?.message === 'string' ? error.message.slice(0, 1_024) : null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function repositoryGitEnvironment(env) {
|
|
69
|
+
const result = { ...env };
|
|
70
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) {
|
|
71
|
+
delete result[key];
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function commonGitDir(repoRoot, env) {
|
|
77
|
+
const result = await git(repoRoot,
|
|
78
|
+
['rev-parse', '--path-format=absolute', '--git-common-dir'], { env });
|
|
79
|
+
const resolved = text(result.stdout);
|
|
80
|
+
if (!path.isAbsolute(resolved)) fail('STORE_COMMIT_GIT_INVALID', 'git_common_dir_not_absolute');
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function resolveTodoStoreCommitLockPath({ repoRoot, env = process.env }) {
|
|
85
|
+
return path.join(await commonGitDir(repoRoot, env), LOCK_NAME);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function head(repoRoot, env) {
|
|
89
|
+
const result = await git(repoRoot, ['rev-parse', '--verify', 'HEAD'], {
|
|
90
|
+
env, allowExitCodes: [0, 128],
|
|
91
|
+
});
|
|
92
|
+
if (result.exitCode !== 0) {
|
|
93
|
+
fail('STORE_COMMIT_HEAD_UNBORN', 'todo_store_commit_requires_head');
|
|
94
|
+
}
|
|
95
|
+
return text(result.stdout);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function headTarget(repoRoot, env) {
|
|
99
|
+
const symbolic = await git(repoRoot, ['symbolic-ref', '-q', 'HEAD'], {
|
|
100
|
+
env, allowExitCodes: [0, 1],
|
|
101
|
+
});
|
|
102
|
+
return symbolic.exitCode === 0 ? text(symbolic.stdout) : 'HEAD';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function storeStatus(repoRoot, env) {
|
|
106
|
+
const result = await git(repoRoot,
|
|
107
|
+
['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', STORE_REF], { env });
|
|
108
|
+
return fields(result.stdout);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function actualIndexPath(repoRoot, env) {
|
|
112
|
+
const result = await git(repoRoot,
|
|
113
|
+
['rev-parse', '--path-format=absolute', '--git-path', 'index'], { env });
|
|
114
|
+
const resolved = text(result.stdout);
|
|
115
|
+
if (!path.isAbsolute(resolved)) fail('STORE_COMMIT_GIT_INVALID', 'git_index_path_not_absolute');
|
|
116
|
+
return resolved;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function syncPath(target) {
|
|
120
|
+
const handle = await open(target, fsConstants.O_RDONLY);
|
|
121
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function prepareSharedIndex({ repoRoot, commitSha, env }) {
|
|
125
|
+
const indexPath = await actualIndexPath(repoRoot, env);
|
|
126
|
+
const lockPath = `${indexPath}.lock`;
|
|
127
|
+
let lockHandle;
|
|
128
|
+
try {
|
|
129
|
+
// 元indexを開いてからlockを作ると、その隙に別processがindexを置換し得る。
|
|
130
|
+
// wxでlock所有を先に確定し、その後に安定した元index bytesを複製する。
|
|
131
|
+
lockHandle = await open(lockPath,
|
|
132
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error?.code === 'EEXIST') {
|
|
135
|
+
fail('STORE_COMMIT_INDEX_BUSY', 'git_index_lock_already_exists', { lock_path: lockPath });
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await lockHandle.writeFile(await readFile(indexPath));
|
|
141
|
+
await lockHandle.sync();
|
|
142
|
+
await lockHandle.close();
|
|
143
|
+
lockHandle = null;
|
|
144
|
+
const isolatedEnv = { ...env, GIT_INDEX_FILE: lockPath };
|
|
145
|
+
await git(repoRoot, ['reset', '--quiet', commitSha, '--', STORE_REF], { env: isolatedEnv });
|
|
146
|
+
const indexCompared = await git(repoRoot,
|
|
147
|
+
['diff', '--quiet', '--cached', commitSha, '--', STORE_REF], {
|
|
148
|
+
env: isolatedEnv, allowExitCodes: [0, 1],
|
|
149
|
+
});
|
|
150
|
+
const worktreeCompared = await git(repoRoot,
|
|
151
|
+
['diff', '--quiet', commitSha, '--', STORE_REF], {
|
|
152
|
+
env: isolatedEnv, allowExitCodes: [0, 1],
|
|
153
|
+
});
|
|
154
|
+
if (indexCompared.exitCode !== 0 || worktreeCompared.exitCode !== 0) {
|
|
155
|
+
fail('STORE_COMMIT_INDEX_DIRTY', 'prepared_todo_store_index_not_clean', {
|
|
156
|
+
index_matches_commit: indexCompared.exitCode === 0,
|
|
157
|
+
worktree_matches_commit: worktreeCompared.exitCode === 0,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
await syncPath(lockPath);
|
|
161
|
+
return {
|
|
162
|
+
indexPath,
|
|
163
|
+
lockPath,
|
|
164
|
+
async commit() {
|
|
165
|
+
await rename(lockPath, indexPath);
|
|
166
|
+
},
|
|
167
|
+
async rollback() {
|
|
168
|
+
await rm(lockPath, { force: true });
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (lockHandle !== null) await lockHandle.close().catch(() => {});
|
|
173
|
+
await rm(lockPath, { force: true });
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function restoreStore({ repoRoot, backupStore, env }) {
|
|
179
|
+
const storeRoot = path.join(repoRoot, STORE_REF);
|
|
180
|
+
await rm(storeRoot, { recursive: true, force: true });
|
|
181
|
+
await mkdir(path.dirname(storeRoot), { recursive: true });
|
|
182
|
+
await cp(backupStore, storeRoot, { recursive: true, force: false, errorOnExist: true });
|
|
183
|
+
const dirty = await storeStatus(repoRoot, env);
|
|
184
|
+
if (dirty.length > 0) {
|
|
185
|
+
fail('STORE_COMMIT_ROLLBACK_FAILED', 'todo_store_rollback_not_clean', { paths: dirty });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function commitIdentity(argv) {
|
|
190
|
+
const firstFlag = argv.findIndex((value) => typeof value === 'string' && value.startsWith('--'));
|
|
191
|
+
const operation = argv.slice(0, firstFlag < 0 ? argv.length : firstFlag).join('-');
|
|
192
|
+
const valueAfter = (flag) => {
|
|
193
|
+
const index = argv.indexOf(flag);
|
|
194
|
+
return index >= 0 && typeof argv[index + 1] === 'string' ? argv[index + 1] : null;
|
|
195
|
+
};
|
|
196
|
+
const plan = valueAfter('--plan');
|
|
197
|
+
const task = valueAfter('--task');
|
|
198
|
+
const phase = valueAfter('--phase');
|
|
199
|
+
const labels = [operation, plan === null ? null : `plan=${plan}`,
|
|
200
|
+
task === null ? null : `task=${task}`, phase === null ? null : `phase=${phase}`]
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
return {
|
|
203
|
+
operation: operation || 'write',
|
|
204
|
+
message: `Lattice ToDo状態更新: ${labels.join(' ')}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function buildDetachedCommit({ repoRoot, commonDir, headBefore, message, env }) {
|
|
209
|
+
const adminDir = await mkdtemp(path.join(commonDir, 'lattice-todo-commit-'));
|
|
210
|
+
const indexPath = path.join(adminDir, 'index');
|
|
211
|
+
const messagePath = path.join(adminDir, 'message');
|
|
212
|
+
try {
|
|
213
|
+
await mkdir(path.join(adminDir, 'logs'), { recursive: true });
|
|
214
|
+
await writeFile(path.join(adminDir, 'commondir'), '..\n', { flag: 'wx' });
|
|
215
|
+
await writeFile(path.join(adminDir, 'HEAD'), `${headBefore}\n`, { flag: 'wx' });
|
|
216
|
+
await writeFile(messagePath, `${message}\n`, { flag: 'wx' });
|
|
217
|
+
const isolatedEnv = {
|
|
218
|
+
...env,
|
|
219
|
+
GIT_DIR: adminDir,
|
|
220
|
+
GIT_WORK_TREE: repoRoot,
|
|
221
|
+
GIT_INDEX_FILE: indexPath,
|
|
222
|
+
};
|
|
223
|
+
await git(repoRoot, ['read-tree', headBefore], { env: isolatedEnv });
|
|
224
|
+
await git(repoRoot, ['add', '-A', '--', STORE_REF], { env: isolatedEnv });
|
|
225
|
+
const changed = fields((await git(repoRoot,
|
|
226
|
+
['diff', '--cached', '--name-only', '-z', '--', STORE_REF], { env: isolatedEnv })).stdout);
|
|
227
|
+
if (changed.length === 0) fail('STORE_COMMIT_NO_CHANGES', 'todo_store_mutation_changed_no_paths');
|
|
228
|
+
if (changed.some((ref) => ref !== STORE_REF && !ref.startsWith(`${STORE_REF}/`))) {
|
|
229
|
+
fail('STORE_COMMIT_SCOPE_VIOLATION', 'todo_store_commit_contains_outside_path', {
|
|
230
|
+
paths: changed,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
await git(repoRoot, ['commit', '--quiet', '--no-status', '--file', messagePath], {
|
|
234
|
+
env: isolatedEnv,
|
|
235
|
+
});
|
|
236
|
+
const commitSha = text((await git(repoRoot, ['rev-parse', 'HEAD'], { env: isolatedEnv })).stdout);
|
|
237
|
+
return { commitSha, changed };
|
|
238
|
+
} finally {
|
|
239
|
+
await rm(adminDir, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function updateHead({ repoRoot, target, commitSha, headBefore, env }) {
|
|
244
|
+
try {
|
|
245
|
+
await git(repoRoot, ['update-ref', target, commitSha, headBefore], { env });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
const current = await head(repoRoot, env).catch(() => null);
|
|
248
|
+
if (current !== headBefore) {
|
|
249
|
+
fail('STORE_COMMIT_HEAD_CONFLICT', 'head_changed_before_store_commit', {
|
|
250
|
+
expected_head: headBefore,
|
|
251
|
+
actual_head: current,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function receipt({ operationResult, commitSha, headBefore, changed, message }) {
|
|
259
|
+
const result = {
|
|
260
|
+
schema: 'lattice.todo_store_atomic_commit_result.v1',
|
|
261
|
+
operation_result: operationResult,
|
|
262
|
+
commit: {
|
|
263
|
+
schema: 'lattice.todo_store_commit_receipt.v1',
|
|
264
|
+
commit_sha: commitSha,
|
|
265
|
+
parent_sha: headBefore,
|
|
266
|
+
paths: changed,
|
|
267
|
+
message,
|
|
268
|
+
},
|
|
269
|
+
result_digest: '',
|
|
270
|
+
};
|
|
271
|
+
result.result_digest = selfDigest(result, 'result_digest');
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Todo store mutationをcommon Git-dir lockの内側で実行し、store pathだけをcommitする。
|
|
277
|
+
* sourceのworking treeと、store以外の共有index entryには触れない。
|
|
278
|
+
*/
|
|
279
|
+
export async function commitTodoStoreMutation({
|
|
280
|
+
repoRoot,
|
|
281
|
+
argv,
|
|
282
|
+
action,
|
|
283
|
+
env = process.env,
|
|
284
|
+
lockTimeoutMs = 0,
|
|
285
|
+
} = {}) {
|
|
286
|
+
if (typeof repoRoot !== 'string' || !path.isAbsolute(repoRoot)
|
|
287
|
+
|| !Array.isArray(argv) || typeof action !== 'function'
|
|
288
|
+
|| env === null || typeof env !== 'object' || Array.isArray(env)
|
|
289
|
+
|| !Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 0) {
|
|
290
|
+
throw new TypeError('commitTodoStoreMutation optionsが不正');
|
|
291
|
+
}
|
|
292
|
+
const gitEnv = repositoryGitEnvironment(env);
|
|
293
|
+
const commonDir = await commonGitDir(repoRoot, gitEnv);
|
|
294
|
+
const { operation, message } = commitIdentity(argv);
|
|
295
|
+
const nonce = randomUUID();
|
|
296
|
+
const lockPath = path.join(commonDir, LOCK_NAME);
|
|
297
|
+
let lock;
|
|
298
|
+
try {
|
|
299
|
+
lock = await acquireRuntimeLifecycleLock({
|
|
300
|
+
lockPath,
|
|
301
|
+
sessionNonceDigest: createHash('sha256').update(nonce).digest('hex'),
|
|
302
|
+
operation: `todo-${operation}`.slice(0, 128),
|
|
303
|
+
requestId: `todo-${process.pid}-${nonce.replaceAll('-', '').slice(0, 16)}`,
|
|
304
|
+
timeoutMs: lockTimeoutMs,
|
|
305
|
+
});
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (error instanceof RuntimeLifecycleLockError) {
|
|
308
|
+
fail(error.code, 'todo_store_commit_lock_failed', {
|
|
309
|
+
lock_path: lockPath,
|
|
310
|
+
reason: error.detail,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
let transactionDir = null;
|
|
316
|
+
let backupStore = null;
|
|
317
|
+
let refUpdated = false;
|
|
318
|
+
let commitSha = null;
|
|
319
|
+
let backupReady = false;
|
|
320
|
+
let preparedIndex = null;
|
|
321
|
+
let primaryFailure = null;
|
|
322
|
+
try {
|
|
323
|
+
try {
|
|
324
|
+
transactionDir = await mkdtemp(path.join(commonDir, 'lattice-todo-rollback-'));
|
|
325
|
+
backupStore = path.join(transactionDir, 'todo');
|
|
326
|
+
const dirty = await storeStatus(repoRoot, gitEnv);
|
|
327
|
+
if (dirty.length > 0) {
|
|
328
|
+
fail('STORE_COMMIT_DIRTY', 'todo_store_dirty_at_atomic_entry', { paths: dirty });
|
|
329
|
+
}
|
|
330
|
+
const storeRoot = path.join(repoRoot, STORE_REF);
|
|
331
|
+
await cp(storeRoot, backupStore, { recursive: true, force: false, errorOnExist: true });
|
|
332
|
+
backupReady = true;
|
|
333
|
+
const headBefore = await head(repoRoot, gitEnv);
|
|
334
|
+
const target = await headTarget(repoRoot, gitEnv);
|
|
335
|
+
const operationResult = await action(repoRoot);
|
|
336
|
+
const built = await buildDetachedCommit({
|
|
337
|
+
repoRoot, commonDir, headBefore, message, env: gitEnv,
|
|
338
|
+
});
|
|
339
|
+
commitSha = built.commitSha;
|
|
340
|
+
preparedIndex = await prepareSharedIndex({ repoRoot, commitSha, env: gitEnv });
|
|
341
|
+
await updateHead({ repoRoot, target, commitSha, headBefore, env: gitEnv });
|
|
342
|
+
refUpdated = true;
|
|
343
|
+
try {
|
|
344
|
+
// 共有index lockを保持したまま、store entryだけ整合済みのindexへatomic renameする。
|
|
345
|
+
await preparedIndex.commit();
|
|
346
|
+
preparedIndex = null;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
try {
|
|
349
|
+
await updateHead({
|
|
350
|
+
repoRoot, target, commitSha: headBefore, headBefore: commitSha, env: gitEnv,
|
|
351
|
+
});
|
|
352
|
+
refUpdated = false;
|
|
353
|
+
} catch (recoveryError) {
|
|
354
|
+
fail('STORE_COMMIT_RECOVERY_REQUIRED', 'todo_store_index_finalize_and_ref_rollback_failed', {
|
|
355
|
+
commit_sha: commitSha,
|
|
356
|
+
index_lock_path: preparedIndex?.lockPath ?? null,
|
|
357
|
+
cause: recoveryError?.code ?? recoveryError?.constructor?.name ?? 'Error',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
if (error instanceof TodoStoreGitTransactionError) throw error;
|
|
361
|
+
fail('STORE_COMMIT_FINALIZE_FAILED', 'todo_store_index_finalize_failed', {
|
|
362
|
+
commit_sha: commitSha,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return receipt({
|
|
366
|
+
operationResult, commitSha, headBefore, changed: built.changed, message,
|
|
367
|
+
});
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (!refUpdated) {
|
|
370
|
+
try {
|
|
371
|
+
if (backupReady) await restoreStore({ repoRoot, backupStore, env: gitEnv });
|
|
372
|
+
} catch (rollbackError) {
|
|
373
|
+
if (rollbackError instanceof TodoStoreGitTransactionError) throw rollbackError;
|
|
374
|
+
fail('STORE_COMMIT_ROLLBACK_FAILED', 'todo_store_rollback_failed', {
|
|
375
|
+
cause: rollbackError?.constructor?.name ?? 'Error',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (error instanceof TypeError || error instanceof TodoStoreGitTransactionError
|
|
380
|
+
|| (typeof error?.code === 'string' && error.detail !== null
|
|
381
|
+
&& typeof error.detail === 'object')) throw error;
|
|
382
|
+
fail('STORE_COMMIT_FAILED', 'todo_store_git_commit_failed', {
|
|
383
|
+
cause: error?.constructor?.name ?? 'Error',
|
|
384
|
+
commit_sha: commitSha,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
primaryFailure = error;
|
|
389
|
+
throw error;
|
|
390
|
+
} finally {
|
|
391
|
+
const cleanupFailures = [];
|
|
392
|
+
const indexLockPath = preparedIndex?.lockPath ?? null;
|
|
393
|
+
if (preparedIndex !== null) {
|
|
394
|
+
try { await preparedIndex.rollback(); }
|
|
395
|
+
catch (error) { cleanupFailures.push(error); }
|
|
396
|
+
}
|
|
397
|
+
if (transactionDir !== null) {
|
|
398
|
+
try { await rm(transactionDir, { recursive: true, force: true }); }
|
|
399
|
+
catch (error) { cleanupFailures.push(error); }
|
|
400
|
+
}
|
|
401
|
+
try { await lock.release(); }
|
|
402
|
+
catch (error) { cleanupFailures.push(error); }
|
|
403
|
+
if (cleanupFailures.length > 0) {
|
|
404
|
+
throw new TodoStoreGitTransactionError(
|
|
405
|
+
refUpdated ? 'STORE_COMMIT_POST_COMMIT_CLEANUP_FAILED' : 'STORE_COMMIT_CLEANUP_FAILED',
|
|
406
|
+
refUpdated ? 'todo_store_commit_succeeded_but_cleanup_failed' : 'todo_store_cleanup_failed',
|
|
407
|
+
{
|
|
408
|
+
commit_sha: refUpdated ? commitSha : null,
|
|
409
|
+
index_lock_path: indexLockPath,
|
|
410
|
+
primary: primaryFailure === null ? null : failureSummary(primaryFailure),
|
|
411
|
+
failures: cleanupFailures.map((error) => (
|
|
412
|
+
typeof error?.code === 'string' ? error.code : error?.constructor?.name ?? 'Error'
|
|
413
|
+
)),
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
package/src/todo-store.mjs
CHANGED
|
@@ -363,6 +363,32 @@ export function projectTodoCoordination(events) {
|
|
|
363
363
|
};
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
/**
|
|
367
|
+
* active plan群へ接続済みのplan跨ぎ依存線を投影する。
|
|
368
|
+
*
|
|
369
|
+
* eventはconsumer(to)planのplan-scoped chainへ積まれる。自動発見も暗黙補完もせず、
|
|
370
|
+
* 記録された線だけを返す。順序はtask identityで固定し、status/Ganttが同じ入力を見る。
|
|
371
|
+
*/
|
|
372
|
+
export function projectTodoCrossPlanDependencies(members) {
|
|
373
|
+
const dependencies = [];
|
|
374
|
+
for (const member of members) for (const event of member.plan_scoped?.events ?? []) {
|
|
375
|
+
if (event.kind !== 'cross_plan_dependency') continue;
|
|
376
|
+
dependencies.push({
|
|
377
|
+
from: event.payload.from,
|
|
378
|
+
to: event.payload.to,
|
|
379
|
+
reason: event.payload.reason,
|
|
380
|
+
connected_by: event.actor,
|
|
381
|
+
connected_at: event.recorded_at,
|
|
382
|
+
event_digest: event.event_digest,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return dependencies.sort((left, right) => {
|
|
386
|
+
const key = (entry) => `${entry.from.project_id}\0${entry.from.plan_key}\0${entry.from.task_id}`
|
|
387
|
+
+ `\0${entry.to.project_id}\0${entry.to.plan_key}\0${entry.to.task_id}\0${entry.event_digest}`;
|
|
388
|
+
return key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
366
392
|
function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
|
|
367
393
|
const state = phaseStates.get(phaseId);
|
|
368
394
|
// ADR 0148: closed_unauditedも他の終端状態と同じく確定済みとして扱う。ここへ足さないと、
|
|
@@ -442,6 +468,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
442
468
|
const phaseStates = new Map(phasesOf(plan).map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
|
|
443
469
|
const doneDigest = new Map();
|
|
444
470
|
const completion = new Map();
|
|
471
|
+
const authoredStartBindings = new Map();
|
|
445
472
|
const importedGenesis = events[0]?.payload.historical_import === true;
|
|
446
473
|
let previousTime = null;
|
|
447
474
|
for (const event of events) {
|
|
@@ -608,7 +635,31 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
608
635
|
fail('STORE_INCONSISTENT', 'invalid_start_transition');
|
|
609
636
|
}
|
|
610
637
|
state.status = 'in-progress'; state.started_at = event.recorded_at;
|
|
638
|
+
authoredStartBindings.set(event.task_id, {
|
|
639
|
+
actor: structuredClone(event.actor), event_digest: event.event_digest,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
} else if (event.kind === 'start_retracted') {
|
|
643
|
+
const binding = authoredStartBindings.get(event.task_id);
|
|
644
|
+
const sameActor = binding !== undefined
|
|
645
|
+
&& binding.actor.host === event.actor.host
|
|
646
|
+
&& binding.actor.session === event.actor.session
|
|
647
|
+
&& binding.actor.agent === event.actor.agent;
|
|
648
|
+
if (state.status !== 'in-progress') {
|
|
649
|
+
fail('START_RETRACTION_INVALID', 'start_retraction_requires_in_progress', {
|
|
650
|
+
task_id: event.task_id, status: state.status,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (binding === undefined || binding.event_digest !== event.payload.target_start_digest) {
|
|
654
|
+
fail('START_RETRACTION_INVALID', 'authored_start_binding_missing', {
|
|
655
|
+
task_id: event.task_id,
|
|
656
|
+
});
|
|
611
657
|
}
|
|
658
|
+
if (!sameActor) {
|
|
659
|
+
fail('START_RETRACTION_INVALID', 'start_actor_mismatch', { task_id: event.task_id });
|
|
660
|
+
}
|
|
661
|
+
Object.assign(state, taskState(event.task_id));
|
|
662
|
+
authoredStartBindings.delete(event.task_id);
|
|
612
663
|
} else if (event.kind === 'block') {
|
|
613
664
|
if (state.status !== 'in-progress') fail('STORE_INCONSISTENT', 'invalid_block_transition');
|
|
614
665
|
state.status = 'blocked'; state.blocked_reason = event.payload.reason;
|
|
@@ -647,6 +698,7 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
647
698
|
completion.set(event.task_id, { mode: 'evidence_promotion', completed_at: current.completed_at });
|
|
648
699
|
}
|
|
649
700
|
doneDigest.set(event.task_id, event.event_digest);
|
|
701
|
+
authoredStartBindings.delete(event.task_id);
|
|
650
702
|
} else if (event.kind === 'reopen') {
|
|
651
703
|
if (state.status !== 'done' || doneDigest.get(event.task_id) !== event.payload.target_done_digest) {
|
|
652
704
|
fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
|
|
@@ -674,6 +726,34 @@ function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSo
|
|
|
674
726
|
return [...states.values()].sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0);
|
|
675
727
|
}
|
|
676
728
|
|
|
729
|
+
/** 最新のauthored startだけを撤回対象として返す。readとappendの双方で同じbindingを検証する。 */
|
|
730
|
+
export function resolveTodoStartRetractionBinding(store, { planKey, taskId, actor }) {
|
|
731
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === planKey);
|
|
732
|
+
if (member === undefined) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
733
|
+
const canonicalTaskId = resolveCanonicalTaskId(member.plan, taskId);
|
|
734
|
+
const task = member.tasks.find(({ task_id: candidate }) => candidate === canonicalTaskId);
|
|
735
|
+
if (task === undefined) fail('STORE_INCONSISTENT', 'event_task_missing');
|
|
736
|
+
if (task.status !== 'in-progress') {
|
|
737
|
+
fail('START_RETRACTION_INVALID', 'start_retraction_requires_in_progress', {
|
|
738
|
+
task_id: canonicalTaskId, status: task.status,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
let binding = null;
|
|
742
|
+
for (const event of member.journal.events) {
|
|
743
|
+
if (event.task_id !== canonicalTaskId) continue;
|
|
744
|
+
if (event.kind === 'start' && event.payload.start_mode !== 'historical_import') binding = event;
|
|
745
|
+
else if (event.kind === 'done' || event.kind === 'start_retracted') binding = null;
|
|
746
|
+
}
|
|
747
|
+
if (binding === null) {
|
|
748
|
+
fail('START_RETRACTION_INVALID', 'authored_start_binding_missing', { task_id: canonicalTaskId });
|
|
749
|
+
}
|
|
750
|
+
if (binding.actor.host !== actor.host || binding.actor.session !== actor.session
|
|
751
|
+
|| binding.actor.agent !== actor.agent) {
|
|
752
|
+
fail('START_RETRACTION_INVALID', 'start_actor_mismatch', { task_id: canonicalTaskId });
|
|
753
|
+
}
|
|
754
|
+
return { task_id: canonicalTaskId, activation_event_digest: binding.event_digest };
|
|
755
|
+
}
|
|
756
|
+
|
|
677
757
|
function snapshotFor(plan, events, tasks) {
|
|
678
758
|
const head = events.at(-1);
|
|
679
759
|
// snapshot artifactの形式は変えない(store canonical形式の非目標)。既存on-disk snapshot
|
|
@@ -694,7 +774,7 @@ function snapshotFor(plan, events, tasks) {
|
|
|
694
774
|
return snapshot;
|
|
695
775
|
}
|
|
696
776
|
|
|
697
|
-
function validateMergedGraph(members) {
|
|
777
|
+
function validateMergedGraph(members, crossPlanDependencies = []) {
|
|
698
778
|
const tasks = new Map();
|
|
699
779
|
for (const member of members) for (const task of member.plan.tasks) {
|
|
700
780
|
tasks.set(`${member.plan.project_id}\0${member.plan.plan_key}\0${task.task_id}`, member.plan.topology_digest);
|
|
@@ -762,6 +842,15 @@ function validateMergedGraph(members) {
|
|
|
762
842
|
}
|
|
763
843
|
}
|
|
764
844
|
}
|
|
845
|
+
for (const dependency of crossPlanDependencies) {
|
|
846
|
+
const owner = members.find(({ plan }) => plan.project_id === dependency.to.project_id
|
|
847
|
+
&& plan.plan_key === dependency.to.plan_key);
|
|
848
|
+
if (owner === undefined) fail('STORE_INCONSISTENT', 'cross_plan_dependency_owner_missing');
|
|
849
|
+
const from = bind(dependency.from, owner.plan);
|
|
850
|
+
const to = bind(dependency.to, owner.plan);
|
|
851
|
+
if (from === to) fail('STORE_INCONSISTENT', 'self_edge');
|
|
852
|
+
adjacency.get(from).push(to);
|
|
853
|
+
}
|
|
765
854
|
const colors = new Map();
|
|
766
855
|
const visit = (node) => {
|
|
767
856
|
if (colors.get(node) === 1) fail('STORE_INCONSISTENT', 'merged_cycle');
|
|
@@ -771,6 +860,48 @@ function validateMergedGraph(members) {
|
|
|
771
860
|
for (const node of adjacency.keys()) visit(node);
|
|
772
861
|
}
|
|
773
862
|
|
|
863
|
+
function crossPlanDependencyTask(store, ref) {
|
|
864
|
+
const member = store.members.find(({ plan }) => plan.project_id === ref.project_id
|
|
865
|
+
&& plan.plan_key === ref.plan_key);
|
|
866
|
+
if (member === undefined) fail('DEPENDENCY_INVALID', 'dependency_plan_not_found', { ref });
|
|
867
|
+
if (member.plan.topology_digest !== ref.expected_topology_digest) {
|
|
868
|
+
fail('DEPENDENCY_STALE', 'dependency_topology_stale', {
|
|
869
|
+
ref, actual_topology_digest: member.plan.topology_digest,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
const task = member.tasks.find(({ task_id: taskId }) => taskId === ref.task_id);
|
|
873
|
+
if (task === undefined) fail('DEPENDENCY_INVALID', 'dependency_task_not_found', { ref });
|
|
874
|
+
return { member, task };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function validateCrossPlanDependencyTransition(store, owner, input) {
|
|
878
|
+
const { from, to } = input.payload;
|
|
879
|
+
if (from.project_id !== store.project_id || to.project_id !== store.project_id) {
|
|
880
|
+
fail('DEPENDENCY_INVALID', 'dependency_project_mismatch');
|
|
881
|
+
}
|
|
882
|
+
if (from.plan_key === to.plan_key) fail('DEPENDENCY_INVALID', 'dependency_must_cross_plans');
|
|
883
|
+
if (to.plan_key !== owner.plan.plan_key || to.project_id !== owner.plan.project_id) {
|
|
884
|
+
fail('DEPENDENCY_INVALID', 'dependency_owner_mismatch');
|
|
885
|
+
}
|
|
886
|
+
const source = crossPlanDependencyTask(store, from);
|
|
887
|
+
const target = crossPlanDependencyTask(store, to);
|
|
888
|
+
if (source.task.status === 'done') fail('DEPENDENCY_INVALID', 'dependency_source_terminal');
|
|
889
|
+
if (target.task.status === 'done') fail('DEPENDENCY_INVALID', 'dependency_target_terminal');
|
|
890
|
+
const existing = projectTodoCrossPlanDependencies(store.members);
|
|
891
|
+
if (existing.some((dependency) => mergedTaskKey(dependency.from) === mergedTaskKey(from)
|
|
892
|
+
&& mergedTaskKey(dependency.to) === mergedTaskKey(to))) {
|
|
893
|
+
fail('DEPENDENCY_EXISTS', 'cross_plan_dependency_duplicate', { from, to });
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
validateMergedGraph(store.members, [...existing, { from, to }]);
|
|
897
|
+
} catch (error) {
|
|
898
|
+
if (error instanceof TodoStoreError && error.detail?.reason === 'merged_cycle') {
|
|
899
|
+
fail('DEPENDENCY_CYCLE', 'cross_plan_dependency_cycle', { from, to });
|
|
900
|
+
}
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
774
905
|
function mergedTaskKey(refValue) {
|
|
775
906
|
return `${refValue.project_id}\0${refValue.plan_key}\0${refValue.task_id}`;
|
|
776
907
|
}
|
|
@@ -835,6 +966,9 @@ function mergedPredecessorKeys(store, targetKey) {
|
|
|
835
966
|
}
|
|
836
967
|
}
|
|
837
968
|
}
|
|
969
|
+
for (const dependency of projectTodoCrossPlanDependencies(store.members)) {
|
|
970
|
+
if (mergedTaskKey(dependency.to) === targetKey) result.push(mergedTaskKey(dependency.from));
|
|
971
|
+
}
|
|
838
972
|
return [...new Set(result)];
|
|
839
973
|
}
|
|
840
974
|
|
|
@@ -850,6 +984,9 @@ function mergedSuccessorKeys(store, targetKey) {
|
|
|
850
984
|
}
|
|
851
985
|
}
|
|
852
986
|
}
|
|
987
|
+
for (const dependency of projectTodoCrossPlanDependencies(store.members)) {
|
|
988
|
+
if (mergedTaskKey(dependency.from) === targetKey) result.push(mergedTaskKey(dependency.to));
|
|
989
|
+
}
|
|
853
990
|
return [...new Set(result)];
|
|
854
991
|
}
|
|
855
992
|
|
|
@@ -1221,7 +1358,7 @@ export async function readTodoStore(options = {}) {
|
|
|
1221
1358
|
snapshot: snapshotStale ? expectedSnapshot : snapshot,
|
|
1222
1359
|
tasks, phases, coordination, snapshot_stale: snapshotStale });
|
|
1223
1360
|
}
|
|
1224
|
-
validateMergedGraph(loaded);
|
|
1361
|
+
validateMergedGraph(loaded, projectTodoCrossPlanDependencies(loaded));
|
|
1225
1362
|
return {
|
|
1226
1363
|
schema: 'lattice.todo_store_read.v1', project_id: manifest.project_id, manifest,
|
|
1227
1364
|
members: loaded, snapshot_stale: loaded.some((member) => member.snapshot_stale),
|
|
@@ -1437,10 +1574,13 @@ export async function appendTodoEvent(options = {}) {
|
|
|
1437
1574
|
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
|
|
1438
1575
|
const member = store.members.find(({ descriptor }) => descriptor.plan_key === options.planKey);
|
|
1439
1576
|
if (!member) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
1440
|
-
// planへ帰属するeventは別chain
|
|
1577
|
+
// planへ帰属するeventは別chainへ積む。task状態もPhase状態も直接は動かさないので、
|
|
1441
1578
|
// replay・snapshot・manifestへは触れない——lifecycle journalのheadを進めるのは
|
|
1442
|
-
//
|
|
1579
|
+
// 「作業が進んだ」の意味であり、方式選択や依存接続でそこを動かすと意味がずれる。
|
|
1443
1580
|
if (TODO_PLAN_SCOPED_EVENT_KINDS.includes(options.event.kind)) {
|
|
1581
|
+
if (options.event.kind === 'cross_plan_dependency') {
|
|
1582
|
+
validateCrossPlanDependencyTransition(store, member, options.event);
|
|
1583
|
+
}
|
|
1444
1584
|
return appendPlanScopedEvent({
|
|
1445
1585
|
repoRoot, member,
|
|
1446
1586
|
input: { ...options.event, recorded_at: options.event.recorded_at ?? new Date().toISOString() },
|