@quolu/lattice 0.21.1 → 0.22.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/package.json +1 -1
- package/src/runtime-diff-observer.mjs +33 -6
- package/src/runtime-engine.mjs +11 -1
- package/src/runtime-seam-treatment.mjs +153 -0
- package/src/seam-apply.mjs +0 -0
- package/src/todo-cli.mjs +4 -1
package/package.json
CHANGED
|
@@ -45,7 +45,7 @@ function sha256(bytes) {
|
|
|
45
45
|
return createHash('sha256').update(bytes).digest('hex');
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function run(command, args, cwd) {
|
|
48
|
+
function run(command, args, cwd, { allowExitCodes = [0] } = {}) {
|
|
49
49
|
return new Promise((resolve, reject) => {
|
|
50
50
|
const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
51
51
|
const stdout = [];
|
|
@@ -54,7 +54,9 @@ function run(command, args, cwd) {
|
|
|
54
54
|
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
55
55
|
child.once('error', reject);
|
|
56
56
|
child.once('close', (code, signal) => {
|
|
57
|
-
if (code
|
|
57
|
+
if (allowExitCodes.includes(code) && signal === null) {
|
|
58
|
+
resolve(Object.assign(Buffer.concat(stdout), { code }));
|
|
59
|
+
}
|
|
58
60
|
else {
|
|
59
61
|
reject(new TypeError(
|
|
60
62
|
`${command} ${args[0]} failed (${signal ?? code}): ${Buffer.concat(stderr).toString('utf8').trim()}`,
|
|
@@ -144,7 +146,13 @@ async function entryRecord(worktreePath, baseSha, entry) {
|
|
|
144
146
|
|
|
145
147
|
/**
|
|
146
148
|
* worktreeの実diffをbounded canonical recordへ変換する。
|
|
147
|
-
*
|
|
149
|
+
*
|
|
150
|
+
* HEADはbaseの子孫へ進んでよい(=workerが自分のworktreeでcommitしてよい)。進んだ分は
|
|
151
|
+
* `base..HEAD`として観測へ含める。子孫でない位置へ動いた場合——reset、branch切替、rebase——は
|
|
152
|
+
* 観測の前提が壊れるのでfail loudする(ADR 0139)。
|
|
153
|
+
*
|
|
154
|
+
* commitを一律禁止していた頃は、進行中の成果が未commitのままworktreeにしか存在せず、
|
|
155
|
+
* 再計画がその変更を含まないsourceに対して行われていた。
|
|
148
156
|
*/
|
|
149
157
|
export async function captureWorktreeDiff(options = {}) {
|
|
150
158
|
if (!exactRecord(options, ['worktreePath', 'baseSha'])) {
|
|
@@ -156,7 +164,12 @@ export async function captureWorktreeDiff(options = {}) {
|
|
|
156
164
|
}
|
|
157
165
|
const head = (await run('git', ['rev-parse', 'HEAD'], worktreePath)).toString('utf8').trim();
|
|
158
166
|
if (head !== baseSha) {
|
|
159
|
-
|
|
167
|
+
const descendant = await run('git', ['merge-base', '--is-ancestor', baseSha, head], worktreePath, {
|
|
168
|
+
allowExitCodes: [0, 1],
|
|
169
|
+
});
|
|
170
|
+
if (descendant.code !== 0) {
|
|
171
|
+
fail(`worktree HEADがbaseの子孫でない(reset・branch切替・rebase): ${head}`);
|
|
172
|
+
}
|
|
160
173
|
}
|
|
161
174
|
// ignored fileへのwriteもwrite sensorの対象にする(gitignore経由の
|
|
162
175
|
// scope violation迂回を塞ぐ。isolation-runnerと同じ--ignored=matching)。
|
|
@@ -164,6 +177,17 @@ export async function captureWorktreeDiff(options = {}) {
|
|
|
164
177
|
'status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=matching',
|
|
165
178
|
], worktreePath);
|
|
166
179
|
const entries = statusEntries(statusBytes);
|
|
180
|
+
// commit済みの変更はstatusへ出ない。base..HEADの範囲も観測へ入れないと、
|
|
181
|
+
// commitした瞬間に変更が観測から消える。
|
|
182
|
+
if (head !== baseSha) {
|
|
183
|
+
const committed = await run('git', [
|
|
184
|
+
'diff', '--name-status', '--no-renames', '-z', `${baseSha}..${head}`,
|
|
185
|
+
], worktreePath);
|
|
186
|
+
const fields = committed.toString('utf8').split('\0').filter((field) => field.length > 0);
|
|
187
|
+
for (let index = 0; index + 1 < fields.length; index += 2) {
|
|
188
|
+
entries.push({ path: fields[index + 1], code: `${fields[index][0]} ` });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
167
191
|
if (entries.length > MAX_DIFF_ENTRIES) {
|
|
168
192
|
fail(`diff entry数が上限を超える: ${entries.length}`);
|
|
169
193
|
}
|
|
@@ -197,12 +221,15 @@ export async function captureWorktreeDiff(options = {}) {
|
|
|
197
221
|
records.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
198
222
|
// 観測中のHEAD移動(TOCTOU)を閉じる: 収集後に再確認する。
|
|
199
223
|
const headAfter = (await run('git', ['rev-parse', 'HEAD'], worktreePath)).toString('utf8').trim();
|
|
200
|
-
if (headAfter !==
|
|
224
|
+
if (headAfter !== head) {
|
|
201
225
|
fail(`観測中にworktree HEADが動いた: ${headAfter}`);
|
|
202
226
|
}
|
|
203
227
|
const record = {
|
|
204
|
-
schema: 'lattice.checkpoint_diff.
|
|
228
|
+
schema: 'lattice.checkpoint_diff.v2',
|
|
205
229
|
base_sha: baseSha,
|
|
230
|
+
// 生み出した木そのものを記録へ縛る。入力側のdigestだけでは、進行中の成果が
|
|
231
|
+
// どの状態だったかを後から指せない。
|
|
232
|
+
head_sha: head,
|
|
206
233
|
entries: records,
|
|
207
234
|
};
|
|
208
235
|
return {
|
package/src/runtime-engine.mjs
CHANGED
|
@@ -31,8 +31,18 @@ import {
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
const ENGINE_ACTOR = 'lattice-runtime';
|
|
34
|
+
/**
|
|
35
|
+
* workerへ配る禁止操作。
|
|
36
|
+
*
|
|
37
|
+
* `commit`は含めない。自分の隔離worktreeでdetached HEADへ進めるcommitは、canonical branchを
|
|
38
|
+
* 動かさず外部へ効果を出さない一方で、進行中の成果を耐久化し、diff観測が生み出した木そのものを
|
|
39
|
+
* 縛れるようにする(ADR 0139)。
|
|
40
|
+
*
|
|
41
|
+
* 禁止のままにするのは、HEADをbaseの子孫から外す操作と、外部へ効果を出す操作である。
|
|
42
|
+
* 前者は観測の前提を壊し、後者は承認なしに行わないという公開契約に当たる。
|
|
43
|
+
*/
|
|
34
44
|
const FORBIDDEN_OPERATIONS = Object.freeze([
|
|
35
|
-
'
|
|
45
|
+
'push', 'branch', 'merge', 'rebase', 'reset', 'stash',
|
|
36
46
|
]);
|
|
37
47
|
|
|
38
48
|
function fail(reason) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 実行時競合を、実際の変換で解消する処置へ変える(請求項8・ADR 0137〜0139)。
|
|
3
|
+
*
|
|
4
|
+
* 実行時の入力は観測である。どのpathでどの2 taskがぶつかったかは`detectCheckpointFindings`が
|
|
5
|
+
* 実際に変更された資源から返すので、ここで影響範囲を探し直さない。
|
|
6
|
+
*
|
|
7
|
+
* 変換の導出はbase状態に対して行う。holdされたTODOは再計画で旧contextを失効させられ、
|
|
8
|
+
* 新plan_ref由来のpacketで再開するため、進行中の編集は設計上そこで捨てられる。
|
|
9
|
+
* `head_sha`が効くのは止めずに継続するcarry-over側であり、held側ではない。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
import { canonicalizeArtifact } from './artifact-contracts.mjs';
|
|
15
|
+
import { selfDigest } from './runtime-contracts.mjs';
|
|
16
|
+
import { seamConflictFromFinding } from './seam-apply.mjs';
|
|
17
|
+
|
|
18
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
19
|
+
const sha16 = (value) => createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16);
|
|
20
|
+
|
|
21
|
+
/** 資源idはidentifier規律なのでpathをそのまま使えない。boundary compilerと同じ合成形にする。 */
|
|
22
|
+
export function pathResourceId(target) {
|
|
23
|
+
return `own-path-${sha16(target)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sealed(value, field) {
|
|
27
|
+
const record = { ...value, [field]: '' };
|
|
28
|
+
record[field] = selfDigest(record, field);
|
|
29
|
+
return record;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalSort(entries) {
|
|
33
|
+
return [...entries].sort((left, right) => compareText(
|
|
34
|
+
canonicalizeArtifact(left), canonicalizeArtifact(right),
|
|
35
|
+
));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 採用された変換から、再計画が読む`lattice.runtime_seam_split.v1`を組む。
|
|
40
|
+
*
|
|
41
|
+
* 所有の差分は「係争資源の所有を降りて、自分の新資源を所有する」である。競合辺は消える——
|
|
42
|
+
* それが変換の目的であり、消えていなければ五条件のoverlap_reducedが通っていない。
|
|
43
|
+
*/
|
|
44
|
+
export function buildRuntimeSeamSplit({
|
|
45
|
+
conflict, candidate, taskMigrationDigest, verifierRefs = [],
|
|
46
|
+
} = {}) {
|
|
47
|
+
const owned = (candidate?.surfaces ?? []).filter(({ role }) => role === 'task_owned');
|
|
48
|
+
if (owned.length < 2) return { split: null, reasons: ['owned_surfaces_below_two'] };
|
|
49
|
+
const sourceResource = pathResourceId(conflict.sourcePath);
|
|
50
|
+
|
|
51
|
+
const removed = canonicalSort(conflict.taskIds.map((taskId) => ({
|
|
52
|
+
resource_id: sourceResource, owner_todo_id: taskId, access_kind: 'own',
|
|
53
|
+
})));
|
|
54
|
+
const added = canonicalSort(owned.map((surface) => ({
|
|
55
|
+
resource_id: pathResourceId(surface.path),
|
|
56
|
+
owner_todo_id: surface.owner_task_ids[0],
|
|
57
|
+
access_kind: 'own',
|
|
58
|
+
})));
|
|
59
|
+
|
|
60
|
+
// 競合辺は消える側だけを載せる。変換で新しい競合を作っていないことは
|
|
61
|
+
// 五条件のoverlap_reduced(plan全体の競合対が増えない)が既に見ている。
|
|
62
|
+
const removedEdges = [];
|
|
63
|
+
for (let left = 0; left < conflict.taskIds.length; left += 1) {
|
|
64
|
+
for (let right = left + 1; right < conflict.taskIds.length; right += 1) {
|
|
65
|
+
const pair = [conflict.taskIds[left], conflict.taskIds[right]].sort(compareText);
|
|
66
|
+
removedEdges.push({ from_todo_id: pair[0], to_todo_id: pair[1], kind: 'conflict' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const split = sealed({
|
|
71
|
+
schema: 'lattice.runtime_seam_split.v1',
|
|
72
|
+
finding_digest: conflict.findingDigest,
|
|
73
|
+
predecessor_task_ids: [...conflict.taskIds].sort(compareText),
|
|
74
|
+
task_migration_digest: taskMigrationDigest,
|
|
75
|
+
ownership_diff: sealed({
|
|
76
|
+
schema: 'lattice.runtime_ownership_diff.v1', added, removed, diff_digest: '',
|
|
77
|
+
}, 'diff_digest'),
|
|
78
|
+
edge_diff: sealed({
|
|
79
|
+
schema: 'lattice.runtime_edge_diff.v1',
|
|
80
|
+
added: [], removed: canonicalSort(removedEdges), diff_digest: '',
|
|
81
|
+
}, 'diff_digest'),
|
|
82
|
+
verifier_refs: [...new Set(verifierRefs)].sort(compareText),
|
|
83
|
+
split_digest: '',
|
|
84
|
+
}, 'split_digest');
|
|
85
|
+
return { split, reasons: [] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 実行時競合の処置を決める。
|
|
90
|
+
*
|
|
91
|
+
* 事前宣言された処置があればそれを使う。無ければ**その場で変換候補を導出して適用を試みる**——
|
|
92
|
+
* ここが請求項8の一手で、従来は事前宣言が無い競合をすべて意図的直列へ倒していた。
|
|
93
|
+
*
|
|
94
|
+
* 変換は五条件を通ったときだけ処置になる。1つでも欠けたら意図的直列へ送り、欠けた条件を残す。
|
|
95
|
+
* 「実行時だから緩める」ことをしない——緩めると、外部挙動を変えうる変更が便益の証明なしに
|
|
96
|
+
* 実行中のrunへ入る。
|
|
97
|
+
*/
|
|
98
|
+
export async function resolveRuntimeSeamTreatment(options = {}) {
|
|
99
|
+
const {
|
|
100
|
+
finding, witnessSet, pathNames = {}, predeclaredTreatments = [],
|
|
101
|
+
applyConflict, baseSha, manifestDigest, affectedTests = [], taskMigrationDigest,
|
|
102
|
+
} = options;
|
|
103
|
+
|
|
104
|
+
if (finding?.kind === 'observed_write_conflict' && typeof finding.path === 'string') {
|
|
105
|
+
for (const treatment of predeclaredTreatments) {
|
|
106
|
+
if (Array.isArray(treatment.covered_paths) && treatment.covered_paths.includes(finding.path)) {
|
|
107
|
+
return { lane: 'seam_transform', treatment: structuredClone(treatment), split: null, reasons: [] };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { conflict, reasons } = seamConflictFromFinding({
|
|
113
|
+
finding, witnessSet, pathNames, affectedTests, baseSha, manifestDigest,
|
|
114
|
+
});
|
|
115
|
+
if (conflict === null) {
|
|
116
|
+
return { lane: 'intentional_serial', treatment: null, split: null, reasons };
|
|
117
|
+
}
|
|
118
|
+
if (typeof applyConflict !== 'function') {
|
|
119
|
+
return {
|
|
120
|
+
lane: 'intentional_serial', treatment: null, split: null, reasons: ['applier_absent'],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const applied = await applyConflict({ conflict });
|
|
125
|
+
if (applied?.outcome?.decision !== 'accepted') {
|
|
126
|
+
return {
|
|
127
|
+
lane: 'intentional_serial',
|
|
128
|
+
treatment: null,
|
|
129
|
+
split: null,
|
|
130
|
+
reasons: [...(applied?.outcome?.reasons ?? ['transform_rejected'])],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const built = buildRuntimeSeamSplit({
|
|
135
|
+
conflict,
|
|
136
|
+
candidate: applied.candidate ?? { surfaces: applied.outcome.surfaces ?? [] },
|
|
137
|
+
taskMigrationDigest,
|
|
138
|
+
verifierRefs: affectedTests,
|
|
139
|
+
});
|
|
140
|
+
if (built.split === null) {
|
|
141
|
+
return { lane: 'intentional_serial', treatment: null, split: null, reasons: built.reasons };
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
lane: 'seam_transform',
|
|
145
|
+
treatment: {
|
|
146
|
+
covered_paths: [conflict.sourcePath],
|
|
147
|
+
candidate_digest: applied.outcome.candidate_digest,
|
|
148
|
+
},
|
|
149
|
+
split: built.split,
|
|
150
|
+
files: applied.files ?? null,
|
|
151
|
+
reasons: [],
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/seam-apply.mjs
CHANGED
|
Binary file
|
package/src/todo-cli.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
lstat, mkdir, open, readFile, realpath, rename, rm, writeFile,
|
|
5
5
|
} from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { parseTree } from 'jsonc-parser';
|
|
8
9
|
|
|
9
10
|
import {
|
|
@@ -988,7 +989,9 @@ async function seamProposalApply({ repoRoot, planKey, pathNames = {}, land = fal
|
|
|
988
989
|
pathNames,
|
|
989
990
|
sourceProposal: artifact,
|
|
990
991
|
witnessSet,
|
|
991
|
-
|
|
992
|
+
// 配布物内の自分自身を指す。repoRoot配下を指すと、Lattice自身のrepositoryでしか動かない
|
|
993
|
+
// ——消費側のprojectに`bin/lattice.mjs`は存在しない。
|
|
994
|
+
latticeBin: fileURLToPath(new URL('../bin/lattice.mjs', import.meta.url)),
|
|
992
995
|
sharedPathFor: (sourcePath) => sourcePath.replace(/(\.[^./]+)$/u, '.seam-shared$1'),
|
|
993
996
|
executors: witnessSet.capacity.executors,
|
|
994
997
|
compileIndependence: {
|