@aipt/platform-kernel 0.1.5

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.
Files changed (182) hide show
  1. package/README.md +25 -0
  2. package/dist/adoption-contracts.d.ts +140 -0
  3. package/dist/adoption-contracts.d.ts.map +1 -0
  4. package/dist/adoption-contracts.js +2 -0
  5. package/dist/adoption-contracts.js.map +1 -0
  6. package/dist/adoption-metadata.d.ts +72 -0
  7. package/dist/adoption-metadata.d.ts.map +1 -0
  8. package/dist/adoption-metadata.js +408 -0
  9. package/dist/adoption-metadata.js.map +1 -0
  10. package/dist/adoption-schemas.d.ts +1608 -0
  11. package/dist/adoption-schemas.d.ts.map +1 -0
  12. package/dist/adoption-schemas.js +319 -0
  13. package/dist/adoption-schemas.js.map +1 -0
  14. package/dist/application-config-binding.d.ts +54 -0
  15. package/dist/application-config-binding.d.ts.map +1 -0
  16. package/dist/application-config-binding.js +139 -0
  17. package/dist/application-config-binding.js.map +1 -0
  18. package/dist/application-config-contract.d.ts +159 -0
  19. package/dist/application-config-contract.d.ts.map +1 -0
  20. package/dist/application-config-contract.js +142 -0
  21. package/dist/application-config-contract.js.map +1 -0
  22. package/dist/blank-profile.d.ts +60 -0
  23. package/dist/blank-profile.d.ts.map +1 -0
  24. package/dist/blank-profile.js +182 -0
  25. package/dist/blank-profile.js.map +1 -0
  26. package/dist/catalog-source.d.ts +167 -0
  27. package/dist/catalog-source.d.ts.map +1 -0
  28. package/dist/catalog-source.js +252 -0
  29. package/dist/catalog-source.js.map +1 -0
  30. package/dist/code-module-publication.d.ts +113 -0
  31. package/dist/code-module-publication.d.ts.map +1 -0
  32. package/dist/code-module-publication.js +71 -0
  33. package/dist/code-module-publication.js.map +1 -0
  34. package/dist/contracts.d.ts +150 -0
  35. package/dist/contracts.d.ts.map +1 -0
  36. package/dist/contracts.js +3 -0
  37. package/dist/contracts.js.map +1 -0
  38. package/dist/delivery.d.ts +201 -0
  39. package/dist/delivery.d.ts.map +1 -0
  40. package/dist/delivery.js +364 -0
  41. package/dist/delivery.js.map +1 -0
  42. package/dist/dependency-resolution.d.ts +46 -0
  43. package/dist/dependency-resolution.d.ts.map +1 -0
  44. package/dist/dependency-resolution.js +35 -0
  45. package/dist/dependency-resolution.js.map +1 -0
  46. package/dist/doctor.d.ts +50 -0
  47. package/dist/doctor.d.ts.map +1 -0
  48. package/dist/doctor.js +62 -0
  49. package/dist/doctor.js.map +1 -0
  50. package/dist/durability-trace.d.ts +31 -0
  51. package/dist/durability-trace.d.ts.map +1 -0
  52. package/dist/durability-trace.js +45 -0
  53. package/dist/durability-trace.js.map +1 -0
  54. package/dist/engineering-operations.d.ts +43 -0
  55. package/dist/engineering-operations.d.ts.map +1 -0
  56. package/dist/engineering-operations.js +19 -0
  57. package/dist/engineering-operations.js.map +1 -0
  58. package/dist/engineering-product.d.ts +261 -0
  59. package/dist/engineering-product.d.ts.map +1 -0
  60. package/dist/engineering-product.js +61 -0
  61. package/dist/engineering-product.js.map +1 -0
  62. package/dist/example-recipe.d.ts +147 -0
  63. package/dist/example-recipe.d.ts.map +1 -0
  64. package/dist/example-recipe.js +58 -0
  65. package/dist/example-recipe.js.map +1 -0
  66. package/dist/feature-planner.d.ts +59 -0
  67. package/dist/feature-planner.d.ts.map +1 -0
  68. package/dist/feature-planner.js +160 -0
  69. package/dist/feature-planner.js.map +1 -0
  70. package/dist/filesystem-contracts.d.ts +77 -0
  71. package/dist/filesystem-contracts.d.ts.map +1 -0
  72. package/dist/filesystem-contracts.js +2 -0
  73. package/dist/filesystem-contracts.js.map +1 -0
  74. package/dist/filesystem-transaction.d.ts +52 -0
  75. package/dist/filesystem-transaction.d.ts.map +1 -0
  76. package/dist/filesystem-transaction.js +749 -0
  77. package/dist/filesystem-transaction.js.map +1 -0
  78. package/dist/index.d.ts +44 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +42 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/integrity.d.ts +3 -0
  83. package/dist/integrity.d.ts.map +1 -0
  84. package/dist/integrity.js +17 -0
  85. package/dist/integrity.js.map +1 -0
  86. package/dist/local-artifact-registry.d.ts +55 -0
  87. package/dist/local-artifact-registry.d.ts.map +1 -0
  88. package/dist/local-artifact-registry.js +87 -0
  89. package/dist/local-artifact-registry.js.map +1 -0
  90. package/dist/locked-repository-state.d.ts +21 -0
  91. package/dist/locked-repository-state.d.ts.map +1 -0
  92. package/dist/locked-repository-state.js +62 -0
  93. package/dist/locked-repository-state.js.map +1 -0
  94. package/dist/managed-region.d.ts +11 -0
  95. package/dist/managed-region.d.ts.map +1 -0
  96. package/dist/managed-region.js +45 -0
  97. package/dist/managed-region.js.map +1 -0
  98. package/dist/package-adoption.d.ts +179 -0
  99. package/dist/package-adoption.d.ts.map +1 -0
  100. package/dist/package-adoption.js +332 -0
  101. package/dist/package-adoption.js.map +1 -0
  102. package/dist/package-release.d.ts +42 -0
  103. package/dist/package-release.d.ts.map +1 -0
  104. package/dist/package-release.js +85 -0
  105. package/dist/package-release.js.map +1 -0
  106. package/dist/plan-state.d.ts +10 -0
  107. package/dist/plan-state.d.ts.map +1 -0
  108. package/dist/plan-state.js +33 -0
  109. package/dist/plan-state.js.map +1 -0
  110. package/dist/planner.d.ts +19 -0
  111. package/dist/planner.d.ts.map +1 -0
  112. package/dist/planner.js +111 -0
  113. package/dist/planner.js.map +1 -0
  114. package/dist/platform-package-sources.d.ts +49 -0
  115. package/dist/platform-package-sources.d.ts.map +1 -0
  116. package/dist/platform-package-sources.js +98 -0
  117. package/dist/platform-package-sources.js.map +1 -0
  118. package/dist/project-bootstrap.d.ts +42 -0
  119. package/dist/project-bootstrap.d.ts.map +1 -0
  120. package/dist/project-bootstrap.js +270 -0
  121. package/dist/project-bootstrap.js.map +1 -0
  122. package/dist/project-control.d.ts +149 -0
  123. package/dist/project-control.d.ts.map +1 -0
  124. package/dist/project-control.js +46 -0
  125. package/dist/project-control.js.map +1 -0
  126. package/dist/project-maintenance.d.ts +30 -0
  127. package/dist/project-maintenance.d.ts.map +1 -0
  128. package/dist/project-maintenance.js +29 -0
  129. package/dist/project-maintenance.js.map +1 -0
  130. package/dist/project-recovery.d.ts +31 -0
  131. package/dist/project-recovery.d.ts.map +1 -0
  132. package/dist/project-recovery.js +80 -0
  133. package/dist/project-recovery.js.map +1 -0
  134. package/dist/recovery-override.d.ts +20 -0
  135. package/dist/recovery-override.d.ts.map +1 -0
  136. package/dist/recovery-override.js +27 -0
  137. package/dist/recovery-override.js.map +1 -0
  138. package/dist/release-loader.d.ts +9 -0
  139. package/dist/release-loader.d.ts.map +1 -0
  140. package/dist/release-loader.js +86 -0
  141. package/dist/release-loader.js.map +1 -0
  142. package/dist/resolution-evidence.d.ts +67 -0
  143. package/dist/resolution-evidence.d.ts.map +1 -0
  144. package/dist/resolution-evidence.js +98 -0
  145. package/dist/resolution-evidence.js.map +1 -0
  146. package/dist/resolution-worker-build.d.ts +33 -0
  147. package/dist/resolution-worker-build.d.ts.map +1 -0
  148. package/dist/resolution-worker-build.js +42 -0
  149. package/dist/resolution-worker-build.js.map +1 -0
  150. package/dist/runtime-observability.d.ts +147 -0
  151. package/dist/runtime-observability.d.ts.map +1 -0
  152. package/dist/runtime-observability.js +264 -0
  153. package/dist/runtime-observability.js.map +1 -0
  154. package/dist/schemas.d.ts +740 -0
  155. package/dist/schemas.d.ts.map +1 -0
  156. package/dist/schemas.js +348 -0
  157. package/dist/schemas.js.map +1 -0
  158. package/dist/solution-catalog.d.ts +169 -0
  159. package/dist/solution-catalog.d.ts.map +1 -0
  160. package/dist/solution-catalog.js +60 -0
  161. package/dist/solution-catalog.js.map +1 -0
  162. package/dist/standalone-npm-profile.d.ts +146 -0
  163. package/dist/standalone-npm-profile.d.ts.map +1 -0
  164. package/dist/standalone-npm-profile.js +425 -0
  165. package/dist/standalone-npm-profile.js.map +1 -0
  166. package/dist/testing.d.ts +5 -0
  167. package/dist/testing.d.ts.map +1 -0
  168. package/dist/testing.js +3 -0
  169. package/dist/testing.js.map +1 -0
  170. package/dist/upgrade-planner.d.ts +45 -0
  171. package/dist/upgrade-planner.d.ts.map +1 -0
  172. package/dist/upgrade-planner.js +207 -0
  173. package/dist/upgrade-planner.js.map +1 -0
  174. package/dist/workspace-publication.d.ts +18 -0
  175. package/dist/workspace-publication.d.ts.map +1 -0
  176. package/dist/workspace-publication.js +33 -0
  177. package/dist/workspace-publication.js.map +1 -0
  178. package/dist/workspace-topology.d.ts +22 -0
  179. package/dist/workspace-topology.d.ts.map +1 -0
  180. package/dist/workspace-topology.js +117 -0
  181. package/dist/workspace-topology.js.map +1 -0
  182. package/package.json +46 -0
@@ -0,0 +1,749 @@
1
+ import { chmod, lstat, mkdir, open, readFile, realpath, rename, rm, stat, unlink, } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { canonicalize, sha256Digest } from "./integrity.js";
7
+ import { calculatePlanId } from "./planner.js";
8
+ import { parseChangePlan, parseTransactionJournal } from "./schemas.js";
9
+ import { traceDurabilityOperation, } from "./durability-trace.js";
10
+ const preparedToken = Symbol("prepared-transaction");
11
+ const activeTransactions = new Map();
12
+ const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
13
+ const DEFAULT_MAX_TRANSACTION_BYTES = 25 * 1024 * 1024;
14
+ const APPLY_DURABILITY_BATCH_SIZE = 8;
15
+ class CrashInjectionSignal extends Error {
16
+ }
17
+ async function runPrepareCheckpoint(hooks, checkpoint, operationIndex) {
18
+ try {
19
+ await hooks?.onCheckpoint?.(checkpoint, operationIndex);
20
+ }
21
+ catch (error) {
22
+ throw new CrashInjectionSignal("Simulated prepare crash", { cause: error });
23
+ }
24
+ }
25
+ export class FilesystemTransactionError extends Error {
26
+ code;
27
+ relativePath;
28
+ operationIndex;
29
+ constructor(code, message, relativePath, operationIndex) {
30
+ super(message);
31
+ this.code = code;
32
+ this.relativePath = relativePath;
33
+ this.operationIndex = operationIndex;
34
+ }
35
+ }
36
+ function transactionPaths(projectRoot, planId) {
37
+ const platformDirectory = path.join(projectRoot, ".dyyto");
38
+ const transactionDirectory = path.join(platformDirectory, "transactions", planId);
39
+ return {
40
+ platformDirectory,
41
+ lock: path.join(platformDirectory, "transaction-lock.json"),
42
+ transactionDirectory,
43
+ journal: path.join(transactionDirectory, "journal.json"),
44
+ preimages: path.join(transactionDirectory, "preimages"),
45
+ quarantine: path.join(transactionDirectory, "quarantine"),
46
+ };
47
+ }
48
+ async function exists(target) {
49
+ try {
50
+ await lstat(target);
51
+ return true;
52
+ }
53
+ catch (error) {
54
+ if (error.code === "ENOENT")
55
+ return false;
56
+ throw error;
57
+ }
58
+ }
59
+ function traceContext(trace, directory = false) {
60
+ if (trace?.sink === undefined)
61
+ return undefined;
62
+ return {
63
+ sink: trace.sink,
64
+ phase: trace.phase,
65
+ pathCategory: directory ? "directory" : trace.category,
66
+ barrierId: trace.barrierId,
67
+ };
68
+ }
69
+ function traceInput(sink, phase, category, barrierId) {
70
+ return sink === undefined ? undefined : { sink, phase, category, barrierId };
71
+ }
72
+ async function syncDirectory(directory, trace) {
73
+ const handle = await open(directory, constants.O_RDONLY);
74
+ try {
75
+ await traceDurabilityOperation({
76
+ context: traceContext(trace, true),
77
+ operation: "directory-sync",
78
+ run: () => handle.sync(),
79
+ });
80
+ }
81
+ finally {
82
+ await handle.close();
83
+ }
84
+ }
85
+ async function writeExclusive(target, bytes, mode = 0o600, trace) {
86
+ const handle = await open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, mode);
87
+ try {
88
+ await traceDurabilityOperation({
89
+ context: traceContext(trace),
90
+ operation: "write",
91
+ bytes: bytes.byteLength,
92
+ run: () => handle.writeFile(bytes),
93
+ });
94
+ await traceDurabilityOperation({
95
+ context: traceContext(trace),
96
+ operation: "file-sync",
97
+ bytes: bytes.byteLength,
98
+ run: () => handle.sync(),
99
+ });
100
+ }
101
+ finally {
102
+ await handle.close();
103
+ }
104
+ }
105
+ async function writeJsonAtomic(target, value, trace) {
106
+ const directory = path.dirname(target);
107
+ const temporary = path.join(directory, `.tmp-${randomUUID()}`);
108
+ const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
109
+ await writeExclusive(temporary, bytes, 0o600, trace);
110
+ await traceDurabilityOperation({
111
+ context: traceContext(trace),
112
+ operation: "rename",
113
+ bytes: bytes.byteLength,
114
+ run: () => rename(temporary, target),
115
+ });
116
+ await syncDirectory(directory, trace);
117
+ }
118
+ function journalWithDigest(journal) {
119
+ return { ...journal, journalDigest: sha256Digest(canonicalize(journal)) };
120
+ }
121
+ function verifyJournal(value) {
122
+ let journal;
123
+ try {
124
+ journal = parseTransactionJournal(value);
125
+ }
126
+ catch {
127
+ throw new FilesystemTransactionError("JOURNAL_CORRUPTED", "Transaction journal shape is invalid");
128
+ }
129
+ const { journalDigest, ...content } = journal;
130
+ if (journalDigest !== sha256Digest(canonicalize(content))) {
131
+ throw new FilesystemTransactionError("JOURNAL_CORRUPTED", "Transaction journal digest or shape is invalid");
132
+ }
133
+ const paths = new Set();
134
+ for (const [index, operation] of journal.operations.entries()) {
135
+ if (operation.index !== index ||
136
+ paths.has(operation.path) ||
137
+ (operation.kind === "create" && operation.before.kind !== "absent") ||
138
+ (operation.kind !== "create" && operation.before.kind !== "present") ||
139
+ (operation.kind !== "remove" && operation.desiredDigest === undefined) ||
140
+ (operation.kind === "remove" && operation.desiredDigest !== undefined)) {
141
+ throw new FilesystemTransactionError("JOURNAL_CORRUPTED", "Transaction journal operation is inconsistent");
142
+ }
143
+ paths.add(operation.path);
144
+ }
145
+ return journal;
146
+ }
147
+ async function readJournal(target) {
148
+ try {
149
+ return verifyJournal(JSON.parse(await readFile(target, "utf8")));
150
+ }
151
+ catch (error) {
152
+ if (error instanceof FilesystemTransactionError)
153
+ throw error;
154
+ throw new FilesystemTransactionError("JOURNAL_CORRUPTED", "Transaction journal cannot be read");
155
+ }
156
+ }
157
+ async function persistJournal(target, journal, trace) {
158
+ const { journalDigest: _previousDigest, ...content } = journal;
159
+ const next = journalWithDigest(content);
160
+ await writeJsonAtomic(target, next, trace);
161
+ return next;
162
+ }
163
+ async function resolveSafeProjectRoot(input) {
164
+ const resolved = path.resolve(input);
165
+ const parsed = path.parse(resolved);
166
+ if (resolved === parsed.root || resolved === path.resolve(homedir())) {
167
+ throw new FilesystemTransactionError("PROJECT_ROOT_UNSAFE", "Project root cannot be the filesystem root or user home");
168
+ }
169
+ const rootStat = await lstat(resolved);
170
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
171
+ throw new FilesystemTransactionError("PROJECT_ROOT_UNSAFE", "Project root must be a real directory");
172
+ }
173
+ const canonical = await realpath(resolved);
174
+ if (canonical !== resolved) {
175
+ throw new FilesystemTransactionError("PROJECT_ROOT_UNSAFE", "Project root cannot resolve through a symbolic link");
176
+ }
177
+ return canonical;
178
+ }
179
+ function resolveTarget(projectRoot, relativePath) {
180
+ if (((relativePath === ".dyyto" || relativePath.startsWith(".dyyto/")) && relativePath !== ".dyyto/application-config-binding.json") ||
181
+ relativePath.length === 0 ||
182
+ path.posix.isAbsolute(relativePath) ||
183
+ path.win32.isAbsolute(relativePath) ||
184
+ relativePath.includes("\\") ||
185
+ relativePath.split("/").some((segment) => segment === "." || segment === "..")) {
186
+ throw new FilesystemTransactionError("PROJECT_ROOT_UNSAFE", "Target path is unsafe", relativePath);
187
+ }
188
+ const target = path.resolve(projectRoot, ...relativePath.split("/"));
189
+ if (!target.startsWith(`${projectRoot}${path.sep}`)) {
190
+ throw new FilesystemTransactionError("PROJECT_ROOT_UNSAFE", "Target escapes project root", relativePath);
191
+ }
192
+ return target;
193
+ }
194
+ async function assertNoSymlink(projectRoot, relativePath) {
195
+ const segments = relativePath.split("/");
196
+ let current = projectRoot;
197
+ for (const segment of segments) {
198
+ current = path.join(current, segment);
199
+ try {
200
+ const item = await lstat(current);
201
+ if (item.isSymbolicLink()) {
202
+ throw new FilesystemTransactionError("PATH_SYMLINK_UNSAFE", "Symbolic links are not valid transaction targets", relativePath);
203
+ }
204
+ }
205
+ catch (error) {
206
+ if (error.code === "ENOENT")
207
+ return;
208
+ throw error;
209
+ }
210
+ }
211
+ }
212
+ async function readCurrent(target) {
213
+ try {
214
+ const item = await lstat(target);
215
+ if (!item.isFile() || item.isSymbolicLink()) {
216
+ throw new FilesystemTransactionError("PATH_SYMLINK_UNSAFE", "Transaction target must be a regular file");
217
+ }
218
+ const bytes = await readFile(target);
219
+ return { bytes, digest: sha256Digest(bytes), mode: item.mode & 0o777 };
220
+ }
221
+ catch (error) {
222
+ if (error.code === "ENOENT")
223
+ return undefined;
224
+ throw error;
225
+ }
226
+ }
227
+ function validatePlanIdentity(plan, approvedPlanId, now) {
228
+ parseChangePlan(plan);
229
+ const { planId: _planId, ...content } = plan;
230
+ if (calculatePlanId(content) !== plan.planId) {
231
+ throw new FilesystemTransactionError("PLAN_CONTENT_MISMATCH", "Plan content does not match planId");
232
+ }
233
+ if (plan.planId !== approvedPlanId) {
234
+ throw new FilesystemTransactionError("PLAN_APPROVAL_MISMATCH", "Approval does not match planId");
235
+ }
236
+ if (plan.state !== "reviewable") {
237
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "Only reviewable plans can be applied");
238
+ }
239
+ if (Date.parse(plan.expiresAt) <= Date.parse(now)) {
240
+ throw new FilesystemTransactionError("PLAN_EXPIRED", "Plan has expired");
241
+ }
242
+ }
243
+ function supportedOperations(plan) {
244
+ const ready = plan.operations.filter((operation) => operation.disposition === "ready");
245
+ for (const operation of ready) {
246
+ if (!(((operation.ownership === "owned-file" || operation.ownership === "seed-file") && ["create", "replace", "remove"].includes(operation.kind)) ||
247
+ (operation.ownership === "managed-region" && operation.kind === "merge" && operation.regionId !== undefined))) {
248
+ throw new FilesystemTransactionError("OPERATION_UNSUPPORTED", "Operation ownership is not supported", operation.path);
249
+ }
250
+ }
251
+ return ready;
252
+ }
253
+ async function acquireLock(lockPath, lock, trace) {
254
+ try {
255
+ await writeExclusive(lockPath, Buffer.from(`${JSON.stringify(lock, null, 2)}\n`), 0o600, trace);
256
+ await syncDirectory(path.dirname(lockPath), trace);
257
+ }
258
+ catch (error) {
259
+ if (error.code === "EEXIST") {
260
+ throw new FilesystemTransactionError("TRANSACTION_ACTIVE", "Another platform transaction is active");
261
+ }
262
+ throw error;
263
+ }
264
+ }
265
+ async function readLock(lockPath) {
266
+ try {
267
+ const value = JSON.parse(await readFile(lockPath, "utf8"));
268
+ if (value.schemaVersion !== 1 ||
269
+ typeof value.transactionId !== "string" ||
270
+ typeof value.planId !== "string" ||
271
+ typeof value.startedAt !== "string" ||
272
+ typeof value.nonce !== "string") {
273
+ throw new Error("invalid lock");
274
+ }
275
+ return value;
276
+ }
277
+ catch {
278
+ throw new FilesystemTransactionError("JOURNAL_CORRUPTED", "Transaction lock is invalid");
279
+ }
280
+ }
281
+ function asPrepared(projectRoot, plan, transactionId) {
282
+ return {
283
+ [preparedToken]: true,
284
+ projectRoot,
285
+ plan,
286
+ transactionId,
287
+ };
288
+ }
289
+ export async function prepareApplyWithHooks(input, hooks) {
290
+ validatePlanIdentity(input.plan, input.approvedPlanId, input.now);
291
+ const projectRoot = await resolveSafeProjectRoot(input.projectRoot);
292
+ const operations = supportedOperations(input.plan);
293
+ const desiredPaths = new Set(input.plan.operations
294
+ .filter((operation) => operation.kind !== "remove" && operation.disposition !== "conflict")
295
+ .map((operation) => operation.path));
296
+ for (const key of input.desiredContents.keys()) {
297
+ if (!desiredPaths.has(key)) {
298
+ throw new FilesystemTransactionError("DESIRED_CONTENT_MISMATCH", "Desired content is not declared by the plan", key);
299
+ }
300
+ }
301
+ for (const operation of operations) {
302
+ resolveTarget(projectRoot, operation.path);
303
+ await assertNoSymlink(projectRoot, operation.path);
304
+ if (operation.kind !== "remove") {
305
+ const bytes = input.desiredContents.get(operation.path);
306
+ if (bytes === undefined || sha256Digest(bytes) !== operation.desiredDigest) {
307
+ throw new FilesystemTransactionError("DESIRED_CONTENT_MISMATCH", "Desired content digest does not match the plan", operation.path);
308
+ }
309
+ }
310
+ }
311
+ const paths = transactionPaths(projectRoot, input.plan.planId);
312
+ await mkdir(paths.platformDirectory, { recursive: true, mode: 0o700 });
313
+ await assertNoSymlink(projectRoot, ".dyyto");
314
+ const transactionId = randomUUID();
315
+ await acquireLock(paths.lock, {
316
+ schemaVersion: 1,
317
+ transactionId,
318
+ planId: input.plan.planId,
319
+ startedAt: input.now,
320
+ nonce: randomUUID(),
321
+ }, traceInput(hooks?.durabilityTrace, "prepare", "lock", `${transactionId}:prepare-lock`));
322
+ const maxFileBytes = input.limits?.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
323
+ const maxTransactionBytes = input.limits?.maxTransactionBytes ?? DEFAULT_MAX_TRANSACTION_BYTES;
324
+ let totalBytes = 0;
325
+ const journalOperations = [];
326
+ const persistedPreimageIndexes = [];
327
+ let createdTransactionDirectory = false;
328
+ try {
329
+ await runPrepareCheckpoint(hooks, "lock-created");
330
+ if (await exists(paths.transactionDirectory)) {
331
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "This plan already has a transaction record");
332
+ }
333
+ await mkdir(paths.preimages, { recursive: true, mode: 0o700 });
334
+ await mkdir(paths.quarantine, { recursive: true, mode: 0o700 });
335
+ createdTransactionDirectory = true;
336
+ for (const [index, operation] of operations.entries()) {
337
+ const target = resolveTarget(projectRoot, operation.path);
338
+ await assertNoSymlink(projectRoot, operation.path);
339
+ const current = await readCurrent(target);
340
+ if (operation.kind === "create") {
341
+ if (current !== undefined) {
342
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "Create target now exists", operation.path, index);
343
+ }
344
+ }
345
+ else if (current === undefined || current.digest !== operation.baseDigest) {
346
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "Target no longer matches plan base digest", operation.path, index);
347
+ }
348
+ let before = { kind: "absent" };
349
+ if (current !== undefined) {
350
+ totalBytes += current.bytes.byteLength;
351
+ if (current.bytes.byteLength > maxFileBytes || totalBytes > maxTransactionBytes) {
352
+ throw new FilesystemTransactionError("PREIMAGE_LIMIT_EXCEEDED", "Preimage size limit exceeded", operation.path, index);
353
+ }
354
+ const preimage = path.join(paths.preimages, `${index}.bin`);
355
+ await writeExclusive(preimage, current.bytes, 0o600, traceInput(hooks?.durabilityTrace, "prepare", "preimage", `${transactionId}:preimage:${index}`));
356
+ persistedPreimageIndexes.push(index);
357
+ await runPrepareCheckpoint(hooks, "preimage-written-pending-directory-sync", index);
358
+ before = {
359
+ kind: "present",
360
+ digest: current.digest,
361
+ mode: current.mode,
362
+ preimageDigest: sha256Digest(current.bytes),
363
+ };
364
+ }
365
+ journalOperations.push({
366
+ index,
367
+ path: operation.path,
368
+ kind: operation.kind,
369
+ before,
370
+ ...(operation.desiredDigest === undefined ? {} : { desiredDigest: operation.desiredDigest }),
371
+ status: "pending",
372
+ });
373
+ }
374
+ if (persistedPreimageIndexes.length > 0) {
375
+ await syncDirectory(paths.preimages, traceInput(hooks?.durabilityTrace, "prepare", "directory", `${transactionId}:preimages-directory-batch`));
376
+ for (const index of persistedPreimageIndexes) {
377
+ await runPrepareCheckpoint(hooks, "preimage-persisted", index);
378
+ }
379
+ }
380
+ let journal = journalWithDigest({
381
+ schemaVersion: 1,
382
+ transactionId,
383
+ planId: input.plan.planId,
384
+ approvedPlanId: input.approvedPlanId,
385
+ projectRootDigest: sha256Digest(projectRoot),
386
+ state: "prepared",
387
+ createdAt: input.now,
388
+ updatedAt: input.now,
389
+ operations: journalOperations,
390
+ });
391
+ await writeJsonAtomic(paths.journal, journal, traceInput(hooks?.durabilityTrace, "prepare", "journal", `${transactionId}:journal-prepared`));
392
+ activeTransactions.set(transactionId, { desiredContents: input.desiredContents, journal });
393
+ await runPrepareCheckpoint(hooks, "journal-prepared");
394
+ return asPrepared(projectRoot, input.plan, transactionId);
395
+ }
396
+ catch (error) {
397
+ if (error instanceof CrashInjectionSignal) {
398
+ activeTransactions.delete(transactionId);
399
+ throw new FilesystemTransactionError("RECOVERY_REQUIRED", "Prepare was interrupted; recovery is required");
400
+ }
401
+ try {
402
+ if (createdTransactionDirectory) {
403
+ await rm(paths.transactionDirectory, { recursive: true, force: true });
404
+ }
405
+ await unlink(paths.lock);
406
+ await syncDirectory(paths.platformDirectory);
407
+ }
408
+ catch {
409
+ throw new FilesystemTransactionError("RECOVERY_REQUIRED", "Prepare failed and transaction lock could not be released");
410
+ }
411
+ throw error;
412
+ }
413
+ }
414
+ export async function prepareApply(input) {
415
+ return prepareApplyWithHooks(input);
416
+ }
417
+ async function markRecoveryRequired(paths, journal, operationIndex, sink) {
418
+ return persistJournal(paths.journal, {
419
+ ...journal,
420
+ state: "recovery-required",
421
+ updatedAt: new Date().toISOString(),
422
+ failure: { code: "APPLY_INTERRUPTED", ...(operationIndex === undefined ? {} : { operationIndex }) },
423
+ }, traceInput(sink, "apply", "journal", `${journal.transactionId}:recovery-required`));
424
+ }
425
+ async function applyOperation(projectRoot, paths, transactionId, operation, desiredContents, hooks) {
426
+ const target = resolveTarget(projectRoot, operation.path);
427
+ await assertNoSymlink(projectRoot, operation.path);
428
+ const current = await readCurrent(target);
429
+ if (operation.before.kind === "absent") {
430
+ if (current !== undefined) {
431
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "Create target changed after prepare", operation.path, operation.index);
432
+ }
433
+ }
434
+ else if (current === undefined || current.digest !== operation.before.digest) {
435
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "Target changed after prepare", operation.path, operation.index);
436
+ }
437
+ if (operation.kind === "remove") {
438
+ const quarantine = path.join(paths.quarantine, `${operation.index}.bin`);
439
+ const trace = traceInput(hooks?.durabilityTrace, "apply", "target", `${transactionId}:target:${operation.index}`);
440
+ await traceDurabilityOperation({
441
+ context: traceContext(trace),
442
+ operation: "rename",
443
+ run: () => rename(target, quarantine),
444
+ });
445
+ await hooks?.onCheckpoint?.("target-renamed-pending-directory-sync", operation.index);
446
+ return [path.dirname(target), paths.quarantine];
447
+ }
448
+ const bytes = desiredContents.get(operation.path);
449
+ if (bytes === undefined || sha256Digest(bytes) !== operation.desiredDigest) {
450
+ throw new FilesystemTransactionError("DESIRED_CONTENT_MISMATCH", "Desired bytes are missing or changed", operation.path, operation.index);
451
+ }
452
+ await mkdir(path.dirname(target), { recursive: true });
453
+ await assertNoSymlink(projectRoot, operation.path);
454
+ const temporary = path.join(path.dirname(target), `.dyyto-${transactionId}-${operation.index}.tmp`);
455
+ const trace = traceInput(hooks?.durabilityTrace, "apply", "target", `${transactionId}:target:${operation.index}`);
456
+ await writeExclusive(temporary, bytes, operation.before.kind === "present" ? operation.before.mode & 0o777 : 0o644, trace);
457
+ await hooks?.onCheckpoint?.("temporary-file-synced", operation.index);
458
+ await traceDurabilityOperation({
459
+ context: traceContext(trace),
460
+ operation: "rename",
461
+ bytes: bytes.byteLength,
462
+ run: () => rename(temporary, target),
463
+ });
464
+ await hooks?.onCheckpoint?.("target-renamed-pending-directory-sync", operation.index);
465
+ return [path.dirname(target)];
466
+ }
467
+ export async function applyPreparedWithHooks(prepared, hooks) {
468
+ const internal = activeTransactions.get(prepared.transactionId);
469
+ if (internal === undefined || prepared[preparedToken] !== true) {
470
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "Prepared transaction is not active");
471
+ }
472
+ const paths = transactionPaths(prepared.projectRoot, prepared.plan.planId);
473
+ let journal = await persistJournal(paths.journal, {
474
+ ...internal.journal,
475
+ state: "applying",
476
+ updatedAt: new Date().toISOString(),
477
+ }, traceInput(hooks?.durabilityTrace, "apply", "journal", `${prepared.transactionId}:applying`));
478
+ try {
479
+ await hooks?.onCheckpoint?.("prepared");
480
+ const directoryBarriers = new Map();
481
+ for (let offset = 0; offset < journal.operations.length; offset += APPLY_DURABILITY_BATCH_SIZE) {
482
+ const operationBatch = journal.operations.slice(offset, offset + APPLY_DURABILITY_BATCH_SIZE);
483
+ const settled = await Promise.allSettled(operationBatch.map((operation) => applyOperation(prepared.projectRoot, paths, prepared.transactionId, operation, internal.desiredContents, hooks)));
484
+ const failure = settled.find((result) => result.status === "rejected");
485
+ if (failure?.status === "rejected")
486
+ throw failure.reason;
487
+ for (const result of settled) {
488
+ if (result.status !== "fulfilled")
489
+ continue;
490
+ for (const directory of result.value) {
491
+ if (!directoryBarriers.has(directory)) {
492
+ directoryBarriers.set(directory, traceInput(hooks?.durabilityTrace, "apply", "directory", `${prepared.transactionId}:target-directory-batch`));
493
+ }
494
+ }
495
+ }
496
+ }
497
+ for (const [directory, trace] of directoryBarriers) {
498
+ await syncDirectory(directory, trace);
499
+ }
500
+ for (const operation of journal.operations) {
501
+ await hooks?.onCheckpoint?.("target-renamed", operation.index);
502
+ operation.status = "applied";
503
+ await hooks?.onCheckpoint?.("operation-applied", operation.index);
504
+ }
505
+ journal = await persistJournal(paths.journal, {
506
+ ...journal,
507
+ operations: journal.operations,
508
+ updatedAt: new Date().toISOString(),
509
+ }, traceInput(hooks?.durabilityTrace, "apply", "journal", `${prepared.transactionId}:operation-batch`));
510
+ for (const operation of journal.operations) {
511
+ await hooks?.onCheckpoint?.("operation-journaled", operation.index);
512
+ }
513
+ journal = await persistJournal(paths.journal, {
514
+ ...journal,
515
+ state: "applied",
516
+ updatedAt: new Date().toISOString(),
517
+ }, traceInput(hooks?.durabilityTrace, "apply", "journal", `${prepared.transactionId}:applied`));
518
+ await hooks?.onCheckpoint?.("applied");
519
+ await unlink(paths.lock);
520
+ await syncDirectory(paths.platformDirectory);
521
+ activeTransactions.delete(prepared.transactionId);
522
+ return snapshot(journal);
523
+ }
524
+ catch (error) {
525
+ journal = await markRecoveryRequired(paths, journal, error instanceof FilesystemTransactionError ? error.operationIndex : undefined, hooks?.durabilityTrace);
526
+ activeTransactions.set(prepared.transactionId, {
527
+ desiredContents: internal.desiredContents,
528
+ journal,
529
+ });
530
+ throw new FilesystemTransactionError("RECOVERY_REQUIRED", "Apply was interrupted; recovery is required");
531
+ }
532
+ }
533
+ export async function applyPrepared(prepared) {
534
+ return applyPreparedWithHooks(prepared);
535
+ }
536
+ function snapshot(journal) {
537
+ return {
538
+ transactionId: journal.transactionId,
539
+ planId: journal.planId,
540
+ state: journal.state,
541
+ operations: journal.operations,
542
+ };
543
+ }
544
+ export async function inspectTransaction(input) {
545
+ const projectRoot = await resolveSafeProjectRoot(input.projectRoot);
546
+ const paths = transactionPaths(projectRoot, input.planId);
547
+ if (!(await exists(paths.journal))) {
548
+ if (!(await exists(paths.lock)))
549
+ return null;
550
+ const lock = await readLock(paths.lock);
551
+ if (lock.planId !== input.planId)
552
+ return null;
553
+ return {
554
+ transactionId: lock.transactionId,
555
+ planId: lock.planId,
556
+ state: "preparing",
557
+ operations: [],
558
+ };
559
+ }
560
+ return snapshot(await readJournal(paths.journal));
561
+ }
562
+ async function restoreBytes(target, bytes, mode) {
563
+ await mkdir(path.dirname(target), { recursive: true });
564
+ const temporary = path.join(path.dirname(target), `.dyyto-recover-${randomUUID()}.tmp`);
565
+ await writeExclusive(temporary, bytes, mode & 0o777);
566
+ await rename(temporary, target);
567
+ await chmod(target, mode & 0o777);
568
+ await syncDirectory(path.dirname(target));
569
+ }
570
+ export async function recoverTransactionWithHooks(input) {
571
+ const projectRoot = await resolveSafeProjectRoot(input.projectRoot);
572
+ const paths = transactionPaths(projectRoot, input.planId);
573
+ if (!(await exists(paths.journal))) {
574
+ if (!(await exists(paths.lock))) {
575
+ throw new FilesystemTransactionError("JOURNAL_CORRUPTED", "No transaction record exists");
576
+ }
577
+ const lock = await readLock(paths.lock);
578
+ if (lock.planId !== input.planId) {
579
+ throw new FilesystemTransactionError("TRANSACTION_ACTIVE", "Transaction lock belongs to another plan");
580
+ }
581
+ await rm(paths.transactionDirectory, { recursive: true, force: true });
582
+ await mkdir(paths.transactionDirectory, { recursive: true, mode: 0o700 });
583
+ const recovered = journalWithDigest({
584
+ schemaVersion: 1,
585
+ transactionId: lock.transactionId,
586
+ planId: lock.planId,
587
+ approvedPlanId: lock.planId,
588
+ projectRootDigest: sha256Digest(projectRoot),
589
+ state: "recovered",
590
+ createdAt: lock.startedAt,
591
+ updatedAt: input.now,
592
+ operations: [],
593
+ });
594
+ await writeJsonAtomic(paths.journal, recovered);
595
+ await unlink(paths.lock);
596
+ await syncDirectory(paths.platformDirectory);
597
+ return snapshot(recovered);
598
+ }
599
+ let journal = await readJournal(paths.journal);
600
+ if (journal.state === "recovered")
601
+ return snapshot(journal);
602
+ if (journal.state === "applied") {
603
+ for (const operation of journal.operations) {
604
+ const current = await readCurrent(resolveTarget(projectRoot, operation.path));
605
+ const matchesAppliedState = operation.kind === "remove"
606
+ ? current === undefined
607
+ : current?.digest === operation.desiredDigest;
608
+ if (!matchesAppliedState) {
609
+ throw new FilesystemTransactionError("RECOVERY_CONFLICT", "Completed transaction target changed before lock cleanup", operation.path, operation.index);
610
+ }
611
+ }
612
+ if (await exists(paths.lock)) {
613
+ const lock = await readLock(paths.lock);
614
+ if (lock.transactionId !== journal.transactionId || lock.planId !== journal.planId) {
615
+ throw new FilesystemTransactionError("TRANSACTION_ACTIVE", "Transaction lock belongs to another transaction");
616
+ }
617
+ await unlink(paths.lock);
618
+ await syncDirectory(paths.platformDirectory);
619
+ }
620
+ activeTransactions.delete(journal.transactionId);
621
+ return snapshot(journal);
622
+ }
623
+ if (journal.projectRootDigest !== sha256Digest(projectRoot)) {
624
+ throw new FilesystemTransactionError("JOURNAL_CORRUPTED", "Journal belongs to a different project root");
625
+ }
626
+ if (await exists(paths.lock)) {
627
+ const lock = await readLock(paths.lock);
628
+ if (lock.transactionId !== journal.transactionId || lock.planId !== journal.planId) {
629
+ throw new FilesystemTransactionError("TRANSACTION_ACTIVE", "Transaction lock belongs to another transaction");
630
+ }
631
+ }
632
+ else {
633
+ await acquireLock(paths.lock, {
634
+ schemaVersion: 1,
635
+ transactionId: journal.transactionId,
636
+ planId: journal.planId,
637
+ startedAt: input.now,
638
+ nonce: randomUUID(),
639
+ });
640
+ }
641
+ journal = await persistJournal(paths.journal, {
642
+ ...journal,
643
+ state: "recovering",
644
+ updatedAt: input.now,
645
+ });
646
+ for (const operation of [...journal.operations].reverse()) {
647
+ if (operation.status === "restored")
648
+ continue;
649
+ const target = resolveTarget(projectRoot, operation.path);
650
+ await assertNoSymlink(projectRoot, operation.path);
651
+ if (operation.kind !== "remove") {
652
+ const temporary = path.join(path.dirname(target), `.dyyto-${journal.transactionId}-${operation.index}.tmp`);
653
+ if (await exists(temporary)) {
654
+ const temporaryState = await readCurrent(temporary);
655
+ if (temporaryState?.digest !== operation.desiredDigest) {
656
+ throw new FilesystemTransactionError("RECOVERY_CONFLICT", "Transaction temporary file was modified externally", operation.path, operation.index);
657
+ }
658
+ await unlink(temporary);
659
+ await syncDirectory(path.dirname(temporary));
660
+ }
661
+ }
662
+ const current = await readCurrent(target);
663
+ if (operation.before.kind === "absent") {
664
+ if (current !== undefined) {
665
+ if (current.digest !== operation.desiredDigest) {
666
+ throw new FilesystemTransactionError("RECOVERY_CONFLICT", "Recovery would overwrite external content", operation.path, operation.index);
667
+ }
668
+ await unlink(target);
669
+ await syncDirectory(path.dirname(target));
670
+ }
671
+ }
672
+ else {
673
+ if (current?.digest === operation.before.digest) {
674
+ // Already restored.
675
+ }
676
+ else {
677
+ const quarantine = path.join(paths.quarantine, `${operation.index}.bin`);
678
+ const desiredWasApplied = current?.digest === operation.desiredDigest;
679
+ const removedWasApplied = operation.kind === "remove" && current === undefined && (await exists(quarantine));
680
+ const approvedConflict = input.approvedConflicts?.find((item) => item.path === operation.path && item.currentDigest === current?.digest);
681
+ if (!desiredWasApplied && !removedWasApplied && !approvedConflict) {
682
+ throw new FilesystemTransactionError("RECOVERY_CONFLICT", "Recovery would overwrite external content", operation.path, operation.index);
683
+ }
684
+ const preimage = await readFile(path.join(paths.preimages, `${operation.index}.bin`));
685
+ if (sha256Digest(preimage) !== operation.before.preimageDigest) {
686
+ throw new FilesystemTransactionError("JOURNAL_CORRUPTED", "Preimage digest is invalid", operation.path, operation.index);
687
+ }
688
+ await restoreBytes(target, preimage, operation.before.mode);
689
+ if (removedWasApplied)
690
+ await rm(quarantine, { force: true });
691
+ }
692
+ }
693
+ operation.status = "restored";
694
+ journal = await persistJournal(paths.journal, {
695
+ ...journal,
696
+ operations: journal.operations,
697
+ updatedAt: input.now,
698
+ });
699
+ await input.hooks?.onCheckpoint?.("operation-journaled", operation.index);
700
+ }
701
+ journal = await persistJournal(paths.journal, {
702
+ ...journal,
703
+ state: "recovered",
704
+ updatedAt: input.now,
705
+ });
706
+ await unlink(paths.lock);
707
+ await syncDirectory(paths.platformDirectory);
708
+ activeTransactions.delete(journal.transactionId);
709
+ return snapshot(journal);
710
+ }
711
+ export async function recoverTransaction(input) {
712
+ return recoverTransactionWithHooks(input);
713
+ }
714
+ export async function recoverTransactionWithApprovedConflicts(input) {
715
+ if (input.approvedConflicts.length === 0 || new Set(input.approvedConflicts.map((item) => item.path)).size !== input.approvedConflicts.length || input.approvedConflicts.some((item) => !/^sha256:[a-f0-9]{64}$/u.test(item.currentDigest)))
716
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "Recovery override conflicts are invalid");
717
+ return recoverTransactionWithHooks(input);
718
+ }
719
+ /**
720
+ * Rolls back a fully applied transaction when a required external adoption step
721
+ * (for example package installation) fails. The same preimages and drift checks
722
+ * used by crash recovery apply; user changes are never overwritten.
723
+ */
724
+ export async function rollbackAppliedTransaction(input) {
725
+ const projectRoot = await resolveSafeProjectRoot(input.projectRoot);
726
+ const paths = transactionPaths(projectRoot, input.planId);
727
+ let journal = await readJournal(paths.journal);
728
+ if (journal.state === "recovered")
729
+ return snapshot(journal);
730
+ if (journal.state !== "applied")
731
+ throw new FilesystemTransactionError("PLAN_PRECONDITION_FAILED", "Only an applied transaction can be rolled back");
732
+ let conflict;
733
+ for (const operation of journal.operations) {
734
+ const current = await readCurrent(resolveTarget(projectRoot, operation.path));
735
+ const matchesApplied = operation.kind === "remove" ? current === undefined : current?.digest === operation.desiredDigest;
736
+ const alreadyRestored = operation.before.kind === "absent" ? current === undefined : current?.digest === operation.before.digest;
737
+ if (!matchesApplied && !alreadyRestored) {
738
+ conflict = operation;
739
+ break;
740
+ }
741
+ }
742
+ await acquireLock(paths.lock, { schemaVersion: 1, transactionId: journal.transactionId, planId: journal.planId, startedAt: input.now, nonce: randomUUID() });
743
+ journal = await persistJournal(paths.journal, { ...journal, state: "recovery-required", updatedAt: input.now, failure: { code: input.failureCode } });
744
+ if (conflict !== undefined)
745
+ throw new FilesystemTransactionError("RECOVERY_CONFLICT", "Rollback would overwrite external content", conflict.path, conflict.index);
746
+ activeTransactions.set(journal.transactionId, { desiredContents: new Map(), journal });
747
+ return recoverTransaction({ projectRoot, planId: input.planId, now: input.now });
748
+ }
749
+ //# sourceMappingURL=filesystem-transaction.js.map