@quolu/lattice 0.25.0 → 0.27.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 +5 -6
- package/src/cli-help.mjs +20 -1
- package/src/runtime-cli.mjs +81 -0
- package/src/runtime-hold-recompile.mjs +3 -0
- package/src/runtime-seam-resolve.mjs +283 -0
- package/src/runtime-seam-treatment.mjs +20 -1
- package/src/seam-apply.mjs +0 -0
- package/src/seam-commit.mjs +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -59,10 +59,9 @@
|
|
|
59
59
|
"prepublishOnly": "npm run verify:release-commit",
|
|
60
60
|
"test": "node scripts/run-product-tests.mjs",
|
|
61
61
|
"test:sensor": "npm --prefix sensor test",
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"verify:todo-store": "node bin/lattice.mjs todo verify --json"
|
|
62
|
+
"check": "node scripts/check-syntax.mjs",
|
|
63
|
+
"ci": "npm run test && npm run test:sensor && npm run check && npm run check:cli-surface && npm run verify:todo-store",
|
|
64
|
+
"verify:todo-store": "node bin/lattice.mjs todo verify --json",
|
|
65
|
+
"check:cli-surface": "node scripts/verify-cli-surface.mjs"
|
|
67
66
|
}
|
|
68
67
|
}
|
package/src/cli-help.mjs
CHANGED
|
@@ -44,6 +44,10 @@ Commands:
|
|
|
44
44
|
close --run .lattice/runs/<id>
|
|
45
45
|
abandon --run .lattice/runs/<id> --reason <reason>
|
|
46
46
|
list --json
|
|
47
|
+
seam resolve --run .lattice/runs/<id> --finding <digest> --input <request.json>
|
|
48
|
+
# 記録済み競合を隔離worktreeで実際に変換し、五条件を通ればseam splitと後継baseを返す。
|
|
49
|
+
# 入力(lattice.runtime_seam_request.v1)へ書くのは、係争fileの中で各TODOが触るsymbolと
|
|
50
|
+
# 新しい面の名前だけ。branchは動かさないので、後継baseへ進めるかは呼び出し側が決める。
|
|
47
51
|
`,
|
|
48
52
|
event: `Usage: lattice event verify --run .lattice/runs/<id>
|
|
49
53
|
`,
|
|
@@ -119,6 +123,8 @@ registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPT
|
|
|
119
123
|
});
|
|
120
124
|
|
|
121
125
|
const SUBCOMMAND_USAGE = Object.freeze({
|
|
126
|
+
status: 'status --json',
|
|
127
|
+
'session-context': 'session-context --json',
|
|
122
128
|
'plan create': 'plan create --input <file> | --schema --json | --schema-version <2|3> --json',
|
|
123
129
|
'plan compile': 'plan compile --request <request.json> | --schema --json',
|
|
124
130
|
'plan verify': 'plan verify --request <request.json> --plan <plan.json>',
|
|
@@ -131,6 +137,13 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
131
137
|
'run close': 'run close --run .lattice/runs/<id>',
|
|
132
138
|
'run abandon': 'run abandon --run .lattice/runs/<id> --reason <reason>',
|
|
133
139
|
'run list': 'run list --json',
|
|
140
|
+
'run activate': 'run activate --run .lattice/runs/<id>',
|
|
141
|
+
'run conflict': 'run conflict --run .lattice/runs/<id> --finding <digest>',
|
|
142
|
+
'run hold': 'run hold --run .lattice/runs/<id> --finding <digest>',
|
|
143
|
+
'run recompile': 'run recompile --run .lattice/runs/<id> --input <recompile-request.json>',
|
|
144
|
+
'run reprocess': 'run reprocess --run .lattice/runs/<id>',
|
|
145
|
+
'run finding record': 'run finding record --run .lattice/runs/<id> --checkpoint <digest> --input <candidate.json>',
|
|
146
|
+
'run seam resolve': 'run seam resolve --run .lattice/runs/<id> --finding <digest> --input <seam-request.json>',
|
|
134
147
|
'event verify': 'event verify --run .lattice/runs/<id>',
|
|
135
148
|
'todo status': 'todo status [--json]',
|
|
136
149
|
'todo bindings': 'todo bindings [--plan <key>] [--json]',
|
|
@@ -155,6 +168,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
155
168
|
'todo revise': 'todo revise --plan <key> --input <file>',
|
|
156
169
|
'todo revise-phase': 'todo revise-phase --plan <key> --input <file>',
|
|
157
170
|
'todo revise-set': 'todo revise-set --input <file>',
|
|
171
|
+
'todo migrate': 'todo migrate --input <extraction.json>',
|
|
158
172
|
'sensor init': 'sensor init [path] --json',
|
|
159
173
|
'sensor sync': 'sensor sync [path] --json',
|
|
160
174
|
'runtime-errors snapshot': 'runtime-errors snapshot [--after-cursor <n>] [--limit <n>] --json',
|
|
@@ -192,5 +206,10 @@ export function renderCliHelp(argv) {
|
|
|
192
206
|
return usage === undefined ? null : `Usage: lattice ${usage}\n`;
|
|
193
207
|
}
|
|
194
208
|
const namespace = requestedNamespace(argv);
|
|
195
|
-
|
|
209
|
+
if (namespace === null) return null;
|
|
210
|
+
// namespaceを持たない1語コマンド(status等)は、SUBCOMMAND_USAGEへ落ちる。
|
|
211
|
+
// 落とさないと「存在するのに使い方を知る手段が無い」コマンドになる。
|
|
212
|
+
if (NAMESPACE_HELP[namespace] !== undefined) return NAMESPACE_HELP[namespace];
|
|
213
|
+
const usage = SUBCOMMAND_USAGE[namespace];
|
|
214
|
+
return usage === undefined ? null : `Usage: lattice ${usage}\n`;
|
|
196
215
|
}
|
package/src/runtime-cli.mjs
CHANGED
|
@@ -15,10 +15,15 @@ import {
|
|
|
15
15
|
writeFile,
|
|
16
16
|
} from 'node:fs/promises';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
18
19
|
|
|
19
20
|
import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
|
|
20
21
|
import { collectSensorEvidence } from './sensor-adapter.mjs';
|
|
21
22
|
import { detectCheckpointFindings } from './runtime-diff-observer.mjs';
|
|
23
|
+
import {
|
|
24
|
+
buildRuntimeSeamResolution, readRuntimeFindingRecord, resolveRuntimeSeam,
|
|
25
|
+
validateRuntimeSeamRequest, verifySeamSplitSuccessor,
|
|
26
|
+
} from './runtime-seam-resolve.mjs';
|
|
22
27
|
import {
|
|
23
28
|
compileRuntimePlanV1,
|
|
24
29
|
evidenceFromCollectedOutcomes,
|
|
@@ -995,6 +1000,51 @@ async function runStart({ requestPath, executorAdapter, cwd, stdout }) {
|
|
|
995
1000
|
return 0;
|
|
996
1001
|
}
|
|
997
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* 記録済み競合findingを、実際の変換で解消する(請求項8・ADR 0137〜0141)。
|
|
1005
|
+
*
|
|
1006
|
+
* 事前宣言されたtreatmentが無い競合は、これが無い間ずっと意図的直列へ退化していた。
|
|
1007
|
+
* 隔離worktreeで変換し、五条件(ADR 0138)を通り、確定できた時だけseam splitを返す。
|
|
1008
|
+
*
|
|
1009
|
+
* branchは動かさない。返す`successor_base_sha`へ進めるかどうかは操作するAIが決める——
|
|
1010
|
+
* 静的側の`land`と同じ責務分担であり、Latticeは変換・検証・記録と、後継baseの検査を持つ。
|
|
1011
|
+
*/
|
|
1012
|
+
async function runSeamResolve({ runDir, repoRoot, findingDigest, requestPath, stdout }) {
|
|
1013
|
+
const committed = await readCommittedEpochStore(runDir);
|
|
1014
|
+
if (committed === null) {
|
|
1015
|
+
throw new CliContractError('RUN_NOT_MANAGED', 'runがmanaged storeへactivateされていない');
|
|
1016
|
+
}
|
|
1017
|
+
const declaration = await readBoundedJson(requestPath, 'runtime seam request');
|
|
1018
|
+
if (!validateRuntimeSeamRequest(declaration)) {
|
|
1019
|
+
throw new CliContractError('INVALID_SEAM_REQUEST', 'lattice.runtime_seam_request.v1として不正');
|
|
1020
|
+
}
|
|
1021
|
+
if (declaration.run_id !== committed.meta.run_id) {
|
|
1022
|
+
throw new CliContractError('INVALID_SEAM_REQUEST', '宣言のrun_idがrun storeと一致しない');
|
|
1023
|
+
}
|
|
1024
|
+
if (declaration.finding_digest !== findingDigest) {
|
|
1025
|
+
throw new CliContractError('INVALID_SEAM_REQUEST', '宣言のfinding_digestが--findingと一致しない');
|
|
1026
|
+
}
|
|
1027
|
+
const found = await readRuntimeFindingRecord({
|
|
1028
|
+
runDir, findingDigest, planEpoch: committed.pointer.plan_epoch,
|
|
1029
|
+
});
|
|
1030
|
+
if (found.record === null) throw new CliContractError('STALE_FINDING', found.reason);
|
|
1031
|
+
|
|
1032
|
+
const resolved = await resolveRuntimeSeam({
|
|
1033
|
+
repoRoot,
|
|
1034
|
+
runDir,
|
|
1035
|
+
findingRecord: found.record,
|
|
1036
|
+
bundle: committed.bundle,
|
|
1037
|
+
declaration,
|
|
1038
|
+
latticeBin: fileURLToPath(new URL('../bin/lattice.mjs', import.meta.url)),
|
|
1039
|
+
compiledAt: canonicalNow(),
|
|
1040
|
+
});
|
|
1041
|
+
const resolution = buildRuntimeSeamResolution({
|
|
1042
|
+
runId: committed.meta.run_id, findingDigest, resolved,
|
|
1043
|
+
});
|
|
1044
|
+
stdout.write(`${JSON.stringify(resolution)}\n`);
|
|
1045
|
+
return resolution.lane === 'seam_transform' ? 0 : 1;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
998
1048
|
async function runObserve({ runDir, stdout }) {
|
|
999
1049
|
const { events } = await readRunStore(runDir);
|
|
1000
1050
|
const chain = verifyRunEventChain({ events });
|
|
@@ -2068,6 +2118,27 @@ export async function runManagedSupervisorDaemon({
|
|
|
2068
2118
|
newPlan.conflicts.sort((left, right) => `${left.todo_ids.join('\0')}\0${left.resource_id}`
|
|
2069
2119
|
.localeCompare(`${right.todo_ids.join('\0')}\0${right.resource_id}`));
|
|
2070
2120
|
}
|
|
2121
|
+
if (recompileRequest.mode === 'seam_split') {
|
|
2122
|
+
// 変換を含まないbaseを指したseam_splitを通さない(ADR 0141)。splitが新しい面の
|
|
2123
|
+
// 所有を宣言するのに後継treeにそのfileが無い、という状態を作らせない。
|
|
2124
|
+
const predecessorBaseSha = active.bundle.request.repo.base_sha;
|
|
2125
|
+
const successorBaseSha = recompileRequest.successor_request.repo.base_sha;
|
|
2126
|
+
const ancestry = await runGit(
|
|
2127
|
+
['merge-base', '--is-ancestor', predecessorBaseSha, successorBaseSha], repoRoot,
|
|
2128
|
+
);
|
|
2129
|
+
const verdict = verifySeamSplitSuccessor({
|
|
2130
|
+
split: recompileRequest.seam_split,
|
|
2131
|
+
predecessorBaseSha,
|
|
2132
|
+
successorBaseSha,
|
|
2133
|
+
successorIsDescendant: ancestry.code === 0,
|
|
2134
|
+
successorConflicts: newPlan.conflicts,
|
|
2135
|
+
successorWitness: recompileRequest.successor_request.manual_witness,
|
|
2136
|
+
});
|
|
2137
|
+
if (!verdict.ok) {
|
|
2138
|
+
throw new ManagedRuntimeError('SEAM_SPLIT_UNPROVEN',
|
|
2139
|
+
`後継baseがseam splitの主張を満たさない: ${verdict.reasons.join(', ')}`);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2071
2142
|
newPlan.plan_digest = selfDigest(newPlan, 'plan_digest');
|
|
2072
2143
|
const executorPackets = buildExecutorPackets({ plan: newPlan, manifests: compiled.manifests });
|
|
2073
2144
|
|
|
@@ -3189,6 +3260,16 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
|
|
|
3189
3260
|
requestId: requestIdOverride,
|
|
3190
3261
|
});
|
|
3191
3262
|
};
|
|
3263
|
+
} else if (argv.length === 9
|
|
3264
|
+
&& argv[0] === 'run' && argv[1] === 'seam' && argv[2] === 'resolve'
|
|
3265
|
+
&& argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
|
|
3266
|
+
&& argv[5] === '--finding' && /^[0-9a-f]{64}$/u.test(argv[6])
|
|
3267
|
+
&& argv[7] === '--input' && typeof argv[8] === 'string' && argv[8].length > 0) {
|
|
3268
|
+
action = async () => {
|
|
3269
|
+
const { repoRoot, runDir } = await resolveRunStore(cwd, argv[4]);
|
|
3270
|
+
return runSeamResolve({ runDir, repoRoot, findingDigest: argv[6],
|
|
3271
|
+
requestPath: path.resolve(cwd, argv[8]), stdout });
|
|
3272
|
+
};
|
|
3192
3273
|
} else if (argv.length === 8
|
|
3193
3274
|
&& argv[0] === 'run' && argv[1] === 'finding' && argv[2] === 'record'
|
|
3194
3275
|
&& argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
|
|
@@ -615,6 +615,9 @@ export function routeConflictTreatment(options = {}) {
|
|
|
615
615
|
* (content不変・epoch/plan refのみ更新)、hold TODOへは新plan_ref由来の
|
|
616
616
|
* 新context packetを発行し、旧contextを失効する。rebind/prepare ack、epoch pointer、
|
|
617
617
|
* controller ready/release ack、中央gate commit前にはepoch_rebound/intake_resumedを発行しない。
|
|
618
|
+
*
|
|
619
|
+
* baseはここでは動かさない。変換で構造が変わった時に後継baseを決めるのは後継run requestで
|
|
620
|
+
* あり、それが本当に変換を含むかは`verifySeamSplitSuccessor`が見る(ADR 0141)。
|
|
618
621
|
*/
|
|
619
622
|
export function recompileNextEpochPlan(options = {}) {
|
|
620
623
|
if (!exactRecord(options, [
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 実行時に観測した競合を、実際の変換で解消するproduct入口(請求項8)。
|
|
3
|
+
*
|
|
4
|
+
* これが無い間、変換の中身は動くのに実運転からそこへ行く道が無かった。実運転側が使う
|
|
5
|
+
* `routeConflictTreatment`は「事前宣言済みtreatmentがpathを覆う時だけseam_transform」なので、
|
|
6
|
+
* **予期しなかった競合は変換にかからず直列へ退化していた**。
|
|
7
|
+
*
|
|
8
|
+
* 装置の境界にAIを含める(AGENTS.md)。したがってここでLatticeが供給するのは、AIが自分では
|
|
9
|
+
* 作れないもの——構造観測、隔離実行、五条件の検証、変換の確定と記録——だけである。
|
|
10
|
+
* 「どのTODOが係争fileの中のどのsymbolを触るか」「新しい面をどう名付けるか」はAIが既に
|
|
11
|
+
* 知っているので、宣言として受け取る。推定しない。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
|
|
18
|
+
import { digestArtifact } from './artifact-contracts.mjs';
|
|
19
|
+
import { commitSeamTransform } from './seam-commit.mjs';
|
|
20
|
+
import { applySeamConflict } from './seam-apply.mjs';
|
|
21
|
+
import { resolveRuntimeSeamTreatment } from './runtime-seam-treatment.mjs';
|
|
22
|
+
import { collectWitnessSensorEvidence, compileTodoIndependence } from './todo-independence.mjs';
|
|
23
|
+
import { todoSelfDigest } from './todo-contracts.mjs';
|
|
24
|
+
|
|
25
|
+
export const RUNTIME_SEAM_REQUEST_SCHEMA = 'lattice.runtime_seam_request.v1';
|
|
26
|
+
export const RUNTIME_SEAM_RESOLUTION_SCHEMA = 'lattice.runtime_seam_resolution.v1';
|
|
27
|
+
|
|
28
|
+
/** 合成するtodo planのkey。実行時planとは別空間なので固定でよい。 */
|
|
29
|
+
const SYNTHETIC_PLAN_KEY = 'runtime';
|
|
30
|
+
const HEX_DIGEST = /^[0-9a-f]{64}$/u;
|
|
31
|
+
const REPO_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[\w./-]+$/u;
|
|
32
|
+
const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
|
|
33
|
+
|
|
34
|
+
const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
35
|
+
|
|
36
|
+
/** 資源idはruntime front-endと同じ合成形にする(`own-<kind>-<sha16>`)。 */
|
|
37
|
+
const sha16 = (value) => createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16);
|
|
38
|
+
|
|
39
|
+
function plainObject(value) {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function exactRecord(value, keys) {
|
|
44
|
+
if (!plainObject(value)) return false;
|
|
45
|
+
const own = Object.keys(value);
|
|
46
|
+
return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* AIが出す宣言。ここに書くのは「係争fileの中で各TODOが触るsymbol」「新しい面の名前」
|
|
51
|
+
* 「後継planへ渡すtask migrationのdigest」だけで、どれもAIが既に持っている情報である。
|
|
52
|
+
*/
|
|
53
|
+
export function validateRuntimeSeamRequest(value) {
|
|
54
|
+
if (!exactRecord(value, [
|
|
55
|
+
'schema', 'run_id', 'finding_digest', 'concern_symbols', 'path_names',
|
|
56
|
+
'task_migration_digest', 'request_digest',
|
|
57
|
+
])) return false;
|
|
58
|
+
if (value.schema !== RUNTIME_SEAM_REQUEST_SCHEMA) return false;
|
|
59
|
+
if (!IDENTIFIER.test(value.run_id ?? '')) return false;
|
|
60
|
+
if (!HEX_DIGEST.test(value.finding_digest ?? '')) return false;
|
|
61
|
+
if (!HEX_DIGEST.test(value.task_migration_digest ?? '')) return false;
|
|
62
|
+
if (!plainObject(value.concern_symbols) || !plainObject(value.path_names)) return false;
|
|
63
|
+
|
|
64
|
+
const todoIds = Object.keys(value.concern_symbols);
|
|
65
|
+
if (todoIds.length < 2 || todoIds.length > 64) return false;
|
|
66
|
+
for (const todoId of todoIds) {
|
|
67
|
+
if (!IDENTIFIER.test(todoId)) return false;
|
|
68
|
+
const symbols = value.concern_symbols[todoId];
|
|
69
|
+
if (!Array.isArray(symbols) || symbols.length === 0 || symbols.length > 256) return false;
|
|
70
|
+
if (!symbols.every((symbol) => IDENTIFIER.test(symbol))) return false;
|
|
71
|
+
if (!REPO_PATH.test(value.path_names[todoId] ?? '')) return false;
|
|
72
|
+
}
|
|
73
|
+
// sharedはresidualから切り出す共有面。TODOに属さないので別枠で必ず要る。
|
|
74
|
+
if (!REPO_PATH.test(value.path_names.shared ?? '')) return false;
|
|
75
|
+
if (!exactRecord(value.path_names, [...todoIds, 'shared'])) return false;
|
|
76
|
+
if (!HEX_DIGEST.test(value.request_digest ?? '')) return false;
|
|
77
|
+
return value.request_digest === todoSelfDigest(value, 'request_digest');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 実行時witnessへconcern anchorを足してtodo witness setにする。
|
|
82
|
+
*
|
|
83
|
+
* 実行時のmanual_witnessはconcern_anchorsを持たない(`lattice.run_request.v3`)。持たせるのでなく、
|
|
84
|
+
* 宣言から足す——係争資源の中のどのsymbolを触るかは実行時に確定する情報であり、run開始時点の
|
|
85
|
+
* 契約に書けるものではないからである。
|
|
86
|
+
*/
|
|
87
|
+
export function buildRuntimeSeamWitnessSet({ request, declaration, contestedPath, executors }) {
|
|
88
|
+
const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
|
|
89
|
+
const manual = {};
|
|
90
|
+
for (const todoId of todoIds) {
|
|
91
|
+
const witness = request.manual_witness?.[todoId];
|
|
92
|
+
if (!plainObject(witness)) return { witnessSet: null, reasons: [`witness_absent:${todoId}`] };
|
|
93
|
+
manual[todoId] = {
|
|
94
|
+
...structuredClone(witness),
|
|
95
|
+
concern_anchors: [{
|
|
96
|
+
within: { kind: 'path', target: contestedPath },
|
|
97
|
+
symbols: [...declaration.concern_symbols[todoId]].sort(compareText),
|
|
98
|
+
}],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const witnessSet = {
|
|
102
|
+
schema: 'lattice.todo_witness_set.v3',
|
|
103
|
+
project_id: SYNTHETIC_PLAN_KEY,
|
|
104
|
+
plan_key: SYNTHETIC_PLAN_KEY,
|
|
105
|
+
capacity: { executors },
|
|
106
|
+
sensor_query_set: structuredClone(request.sensor_query_set),
|
|
107
|
+
manual_witness: manual,
|
|
108
|
+
witness_set_digest: '',
|
|
109
|
+
};
|
|
110
|
+
witnessSet.witness_set_digest = todoSelfDigest(witnessSet, 'witness_set_digest');
|
|
111
|
+
return { witnessSet, reasons: [] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function syntheticTodoPlan(todoIds) {
|
|
115
|
+
return {
|
|
116
|
+
schema: 'lattice.todo_plan.v2',
|
|
117
|
+
project_id: SYNTHETIC_PLAN_KEY,
|
|
118
|
+
plan_key: SYNTHETIC_PLAN_KEY,
|
|
119
|
+
plan_version: 'v1',
|
|
120
|
+
topology_digest: digestArtifact({ tasks: [...todoIds].sort(compareText) }),
|
|
121
|
+
tasks: [...todoIds].sort(compareText).map((taskId) => ({ task_id: taskId })),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 記録済みfindingと宣言から、変換を導出・適用・検証・確定して処置を返す。
|
|
127
|
+
*
|
|
128
|
+
* 五条件(ADR 0138)を1つでも欠いたら意図的直列を返す。確定できなければ採用しない(ADR 0141)。
|
|
129
|
+
* 「変換した」と言いながら再開できない状態を作らない。
|
|
130
|
+
*/
|
|
131
|
+
export async function resolveRuntimeSeam({
|
|
132
|
+
repoRoot, runDir, findingRecord, bundle, declaration, latticeBin, compiledAt,
|
|
133
|
+
} = {}) {
|
|
134
|
+
const finding = findingRecord.finding;
|
|
135
|
+
const request = bundle.request;
|
|
136
|
+
const baseSha = request.repo.base_sha;
|
|
137
|
+
const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
|
|
138
|
+
|
|
139
|
+
if (finding.kind !== 'observed_write_conflict' || typeof finding.path !== 'string') {
|
|
140
|
+
return { lane: 'intentional_serial', reasons: ['finding_not_write_conflict'], split: null };
|
|
141
|
+
}
|
|
142
|
+
const findingTodoIds = [...finding.todo_ids].sort(compareText);
|
|
143
|
+
if (findingTodoIds.join('\0') !== todoIds.join('\0')) {
|
|
144
|
+
return {
|
|
145
|
+
lane: 'intentional_serial',
|
|
146
|
+
reasons: ['declared_todos_differ_from_finding'],
|
|
147
|
+
split: null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const built = buildRuntimeSeamWitnessSet({
|
|
152
|
+
request, declaration, contestedPath: finding.path,
|
|
153
|
+
executors: request.capacity.executors,
|
|
154
|
+
});
|
|
155
|
+
if (built.witnessSet === null) {
|
|
156
|
+
return { lane: 'intentional_serial', reasons: built.reasons, split: null };
|
|
157
|
+
}
|
|
158
|
+
const witnessSet = built.witnessSet;
|
|
159
|
+
const plan = syntheticTodoPlan(todoIds);
|
|
160
|
+
|
|
161
|
+
// 観測したaffected testsだけを検証に使う。宣言から発明しない。
|
|
162
|
+
const affectedTests = [...new Set(todoIds
|
|
163
|
+
.flatMap((todoId) => request.manual_witness[todoId].affected_tests))].sort(compareText);
|
|
164
|
+
|
|
165
|
+
const baseArtifact = compileTodoIndependence({
|
|
166
|
+
witnessSet, plan, baseSha, compiledAt,
|
|
167
|
+
sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const pathNames = { ...declaration.path_names };
|
|
171
|
+
return resolveRuntimeSeamTreatment({
|
|
172
|
+
finding,
|
|
173
|
+
witnessSet,
|
|
174
|
+
pathNames,
|
|
175
|
+
baseSha,
|
|
176
|
+
manifestDigest: baseArtifact.result_digest,
|
|
177
|
+
affectedTests,
|
|
178
|
+
taskMigrationDigest: declaration.task_migration_digest,
|
|
179
|
+
commitTransform: async ({ files, candidateId }) => commitSeamTransform({
|
|
180
|
+
repoRoot, baseSha, files, candidateId,
|
|
181
|
+
}),
|
|
182
|
+
applyConflict: async ({ conflict }) => {
|
|
183
|
+
const applied = await applySeamConflict({
|
|
184
|
+
repoRoot,
|
|
185
|
+
planKey: SYNTHETIC_PLAN_KEY,
|
|
186
|
+
conflict,
|
|
187
|
+
witnessSet,
|
|
188
|
+
latticeBin,
|
|
189
|
+
sharedPathFor: () => declaration.path_names.shared,
|
|
190
|
+
executors: request.capacity.executors,
|
|
191
|
+
pathNames,
|
|
192
|
+
compileIndependence: {
|
|
193
|
+
baseArtifact,
|
|
194
|
+
inWorktree: async ({ worktreePath, witnessSet: postWitness }) => compileTodoIndependence({
|
|
195
|
+
witnessSet: postWitness,
|
|
196
|
+
plan,
|
|
197
|
+
baseSha,
|
|
198
|
+
compiledAt,
|
|
199
|
+
sensorEvidence: await collectWitnessSensorEvidence({
|
|
200
|
+
cwd: worktreePath, witnessSet: postWitness,
|
|
201
|
+
}),
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
return { ...applied, candidate: applied.candidate ?? null };
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 決着をartifactにする。branchを動かすのは操作するAIなので、行き先を明示して返す。 */
|
|
211
|
+
export function buildRuntimeSeamResolution({ runId, findingDigest, resolved }) {
|
|
212
|
+
const resolution = {
|
|
213
|
+
schema: RUNTIME_SEAM_RESOLUTION_SCHEMA,
|
|
214
|
+
run_id: runId,
|
|
215
|
+
finding_digest: findingDigest,
|
|
216
|
+
lane: resolved.lane,
|
|
217
|
+
reasons: [...resolved.reasons].sort(compareText),
|
|
218
|
+
split: resolved.split ?? null,
|
|
219
|
+
successor_base_sha: resolved.successor_base_sha ?? null,
|
|
220
|
+
successor_base_ref: resolved.successor_base_ref ?? null,
|
|
221
|
+
resolution_digest: '',
|
|
222
|
+
};
|
|
223
|
+
resolution.resolution_digest = todoSelfDigest(resolution, 'resolution_digest');
|
|
224
|
+
return resolution;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* seam_split再計画で、後継baseが本当に変換を含むかを検査する(ADR 0141)。
|
|
229
|
+
*
|
|
230
|
+
* これが無い間、`mode: 'seam_split'`の再計画requestは**変換を含まないbaseを指していても通った**。
|
|
231
|
+
* splitは新しい面の所有を宣言するのに、compileされる後継treeにそのfileが無い——rb工程で
|
|
232
|
+
* 直したのと同じ欠陥が、管理runtimeの層に残っていた。
|
|
233
|
+
*
|
|
234
|
+
* 検査するのは3つ。どれもLatticeが既に持っているartifactだけで判定でき、推定を含まない。
|
|
235
|
+
*
|
|
236
|
+
* 1. baseが前進し、かつ旧baseの子孫であること。変換が着地していなければ前進しない。
|
|
237
|
+
* 2. splitが「消える」と述べた競合辺が、後継planに実際に無いこと。後継treeに変換が
|
|
238
|
+
* 載っていなければ両TODOは同じfileを書き続けるので、この辺は消えない。
|
|
239
|
+
* 3. splitが新たに所有すると述べた資源が、後継requestで**creationとして宣言されていない**こと。
|
|
240
|
+
* seam splitは既存codeを新しい面へ移す操作であり、変換が既に作っている。これから作る、
|
|
241
|
+
* と宣言されているなら、指しているbaseは変換前である。
|
|
242
|
+
*/
|
|
243
|
+
export function verifySeamSplitSuccessor({
|
|
244
|
+
split, predecessorBaseSha, successorBaseSha, successorIsDescendant,
|
|
245
|
+
successorConflicts = [], successorWitness = {},
|
|
246
|
+
} = {}) {
|
|
247
|
+
const reasons = [];
|
|
248
|
+
if (successorBaseSha === predecessorBaseSha) reasons.push('successor_base_not_advanced');
|
|
249
|
+
else if (successorIsDescendant !== true) reasons.push('successor_base_not_descendant');
|
|
250
|
+
|
|
251
|
+
const removedEdges = split?.edge_diff?.removed ?? [];
|
|
252
|
+
const remaining = new Set(successorConflicts
|
|
253
|
+
.map(({ todo_ids: ids }) => [...ids].sort(compareText).join('\0')));
|
|
254
|
+
for (const edge of removedEdges) {
|
|
255
|
+
const key = [edge.from_todo_id, edge.to_todo_id].sort(compareText).join('\0');
|
|
256
|
+
if (remaining.has(key)) reasons.push(`declared_removed_conflict_persists:${key.replace('\0', ',')}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const added of split?.ownership_diff?.added ?? []) {
|
|
260
|
+
const owns = successorWitness[added.owner_todo_id]?.owns ?? [];
|
|
261
|
+
for (const own of owns) {
|
|
262
|
+
const resourceId = `own-${own.kind}-${sha16(own.target)}`;
|
|
263
|
+
if (resourceId === added.resource_id && own.creates === true) {
|
|
264
|
+
reasons.push(`declared_owned_surface_is_creation:${added.owner_todo_id}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { ok: reasons.length === 0, reasons: [...new Set(reasons)].sort(compareText) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `<runDir>/findings/<digest>.json`を読む。存在しない/別epochのfindingは受けない。 */
|
|
272
|
+
export async function readRuntimeFindingRecord({ runDir, findingDigest, planEpoch }) {
|
|
273
|
+
const filePath = path.join(runDir, 'findings', `${findingDigest}.json`);
|
|
274
|
+
let record;
|
|
275
|
+
try {
|
|
276
|
+
record = JSON.parse(await readFile(filePath, 'utf8'));
|
|
277
|
+
} catch {
|
|
278
|
+
return { record: null, reason: 'finding_not_recorded' };
|
|
279
|
+
}
|
|
280
|
+
if (record?.finding_digest !== findingDigest) return { record: null, reason: 'finding_digest_mismatch' };
|
|
281
|
+
if (record.plan_epoch !== planEpoch) return { record: null, reason: 'finding_from_other_epoch' };
|
|
282
|
+
return { record, reason: null };
|
|
283
|
+
}
|
|
@@ -98,7 +98,8 @@ export function buildRuntimeSeamSplit({
|
|
|
98
98
|
export async function resolveRuntimeSeamTreatment(options = {}) {
|
|
99
99
|
const {
|
|
100
100
|
finding, witnessSet, pathNames = {}, predeclaredTreatments = [],
|
|
101
|
-
applyConflict, baseSha, manifestDigest, affectedTests = [],
|
|
101
|
+
applyConflict, commitTransform, baseSha, manifestDigest, affectedTests = [],
|
|
102
|
+
taskMigrationDigest,
|
|
102
103
|
} = options;
|
|
103
104
|
|
|
104
105
|
if (finding?.kind === 'observed_write_conflict' && typeof finding.path === 'string') {
|
|
@@ -122,6 +123,13 @@ export async function resolveRuntimeSeamTreatment(options = {}) {
|
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
const applied = await applyConflict({ conflict });
|
|
126
|
+
if (applied?.outcome?.decision === 'accepted' && typeof commitTransform !== 'function') {
|
|
127
|
+
// 確定する手段が無いまま採用すると、splitは所有を宣言するのに再開先へその成果が無い。
|
|
128
|
+
// 「変換した」と言いながら再開できない状態を作らない(ADR 0141)。
|
|
129
|
+
return {
|
|
130
|
+
lane: 'intentional_serial', treatment: null, split: null, reasons: ['committer_absent'],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
125
133
|
if (applied?.outcome?.decision !== 'accepted') {
|
|
126
134
|
return {
|
|
127
135
|
lane: 'intentional_serial',
|
|
@@ -140,6 +148,15 @@ export async function resolveRuntimeSeamTreatment(options = {}) {
|
|
|
140
148
|
if (built.split === null) {
|
|
141
149
|
return { lane: 'intentional_serial', treatment: null, split: null, reasons: built.reasons };
|
|
142
150
|
}
|
|
151
|
+
// 再開先が実在するbaseを要る。branchを動かさずcommitとして確定し、そのshaを後継planへ渡す。
|
|
152
|
+
const committed = await commitTransform({
|
|
153
|
+
conflict, files: applied.files ?? {}, candidateId: conflict.candidateId,
|
|
154
|
+
});
|
|
155
|
+
if (!/^[0-9a-f]{40}$/u.test(committed?.commitSha ?? '')) {
|
|
156
|
+
return {
|
|
157
|
+
lane: 'intentional_serial', treatment: null, split: null, reasons: ['transform_not_committed'],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
143
160
|
return {
|
|
144
161
|
lane: 'seam_transform',
|
|
145
162
|
treatment: {
|
|
@@ -148,6 +165,8 @@ export async function resolveRuntimeSeamTreatment(options = {}) {
|
|
|
148
165
|
},
|
|
149
166
|
split: built.split,
|
|
150
167
|
files: applied.files ?? null,
|
|
168
|
+
successor_base_sha: committed.commitSha,
|
|
169
|
+
successor_base_ref: committed.ref ?? null,
|
|
151
170
|
reasons: [],
|
|
152
171
|
};
|
|
153
172
|
}
|
package/src/seam-apply.mjs
CHANGED
|
Binary file
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 採用された変換を、branchを動かさずにcommitとして確定する(ADR 0141)。
|
|
3
|
+
*
|
|
4
|
+
* 再開先が実在するbaseを要る。後継planへ旧baseを渡すと、splitが「T1は新pathを所有する」と
|
|
5
|
+
* 述べるのに、再開したworkerのworktreeにそのfileが無い——所有すると宣言されたものが存在しない。
|
|
6
|
+
*
|
|
7
|
+
* canonical branchへは出さない。detached HEADでcommitし、`refs/lattice/seam/<id>`へ繋いでGCから
|
|
8
|
+
* 守る。branchは動かず外部へ効果を出さないまま、worktreeを張れる実在のcommitになる。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { promisify } from 'node:util';
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const GIT_SHA1 = /^[0-9a-f]{40}$/u;
|
|
19
|
+
const SEAM_REF_PREFIX = 'refs/lattice/seam';
|
|
20
|
+
|
|
21
|
+
async function git(args, cwd) {
|
|
22
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
23
|
+
cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
return stdout;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 変換の成果を指すref。branch名前空間へ置かないので、通常のbranch一覧には現れない。 */
|
|
29
|
+
export function seamRefFor(candidateId) {
|
|
30
|
+
return `${SEAM_REF_PREFIX}/${candidateId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeRelative(target) {
|
|
34
|
+
return typeof target === 'string' && target.length > 0 && !target.includes('\0')
|
|
35
|
+
&& !path.posix.isAbsolute(target) && target === path.posix.normalize(target)
|
|
36
|
+
&& !target.split('/').includes('..');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 採用された変換をcommitへ確定し、そのshaを返す。
|
|
41
|
+
*
|
|
42
|
+
* 本repositoryの作業ツリーは触らない。使い捨てworktreeをbaseへ張り、変換後のfileを書き、
|
|
43
|
+
* detached HEADでcommitし、refへ繋いでからworktreeを畳む。commit objectはobject DBを共有する
|
|
44
|
+
* ので、worktreeを消してもshaは生き残る——refはそれをGCから守るためである。
|
|
45
|
+
*
|
|
46
|
+
* @param {object} options
|
|
47
|
+
* @param {string} options.repoRoot 本repository
|
|
48
|
+
* @param {string} options.baseSha 変換前のbase
|
|
49
|
+
* @param {object} options.files pathごとの変換後text
|
|
50
|
+
* @param {string} options.candidateId ref名に使う識別子
|
|
51
|
+
* @param {string} options.message commit message
|
|
52
|
+
* @returns {Promise<{commitSha: string, ref: string}>}
|
|
53
|
+
*/
|
|
54
|
+
export async function commitSeamTransform({
|
|
55
|
+
repoRoot, baseSha, files, candidateId, message,
|
|
56
|
+
} = {}) {
|
|
57
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) throw new TypeError('repoRootが不正');
|
|
58
|
+
if (!GIT_SHA1.test(baseSha ?? '')) throw new TypeError('baseShaが不正');
|
|
59
|
+
if (files === null || typeof files !== 'object' || Array.isArray(files)
|
|
60
|
+
|| Object.keys(files).length === 0) throw new TypeError('filesが不正');
|
|
61
|
+
const targets = Object.keys(files);
|
|
62
|
+
if (!targets.every(safeRelative)) throw new TypeError('変換後pathがrepo相対規律を満たさない');
|
|
63
|
+
if (typeof candidateId !== 'string' || !/^[0-9A-Za-z][\w.-]{0,127}$/u.test(candidateId)) {
|
|
64
|
+
throw new TypeError('candidateIdが不正');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const worktreeRoot = await mkdtemp(path.join(tmpdir(), 'lattice-seam-commit-'));
|
|
68
|
+
const worktreePath = path.join(worktreeRoot, 'tree');
|
|
69
|
+
let commitSha;
|
|
70
|
+
try {
|
|
71
|
+
await git(['worktree', 'add', '--detach', '--quiet', worktreePath, baseSha], repoRoot);
|
|
72
|
+
for (const [target, text] of Object.entries(files)) {
|
|
73
|
+
const absolute = path.join(worktreePath, target);
|
|
74
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
75
|
+
await writeFile(absolute, text);
|
|
76
|
+
}
|
|
77
|
+
await git(['add', '--', ...targets], worktreePath);
|
|
78
|
+
// 変換で1 byteも変わらなかったなら、確定すべき成果が無い。空commitで
|
|
79
|
+
// 「進んだ」ように見せない。
|
|
80
|
+
const staged = await git(['diff', '--cached', '--name-only'], worktreePath);
|
|
81
|
+
if (staged.trim() === '') throw new TypeError('変換後の差分が無い');
|
|
82
|
+
await git([
|
|
83
|
+
'-c', 'user.email=lattice@localhost', '-c', 'user.name=lattice',
|
|
84
|
+
'commit', '--quiet', '-m', message ?? `seam transform ${candidateId}`,
|
|
85
|
+
], worktreePath);
|
|
86
|
+
commitSha = (await git(['rev-parse', 'HEAD'], worktreePath)).trim();
|
|
87
|
+
if (!GIT_SHA1.test(commitSha)) throw new TypeError('commit shaが不正');
|
|
88
|
+
} finally {
|
|
89
|
+
await git(['worktree', 'remove', '--force', worktreePath], repoRoot).catch(() => {});
|
|
90
|
+
await rm(worktreeRoot, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// worktreeを畳んだ後にrefを張る。object DBは共有なのでshaは生きているが、
|
|
94
|
+
// refが無いとGCの対象になる。
|
|
95
|
+
const ref = seamRefFor(candidateId);
|
|
96
|
+
await git(['update-ref', ref, commitSha], repoRoot);
|
|
97
|
+
return { commitSha, ref };
|
|
98
|
+
}
|