amalgm 0.1.238 → 0.1.240

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 (86) hide show
  1. package/lib/shared-realtime-tunnel.js +22 -14
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
  4. package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
  5. package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
  6. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  7. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  8. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  9. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  10. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  11. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  12. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  13. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  14. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  15. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  16. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  17. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  18. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  19. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  20. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  21. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +81 -3
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  41. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  42. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  43. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  44. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  45. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  46. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  48. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  49. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  50. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
  67. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  68. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  69. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  70. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  71. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  72. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  73. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  74. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  83. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  86. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -0,0 +1,535 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Durable local authorship for one registered code folder.
5
+ *
6
+ * The official stream still has only two values: card and checkpoint. This
7
+ * file is not shared state. It records the exact local state a real file/Git
8
+ * observation saw, relative to the last verified official state on this
9
+ * machine. The reducer may preserve only this record; a physical mismatch by
10
+ * itself never creates authorship.
11
+ *
12
+ * Axiom 4: a reserved predecessor is immutable and later work occupies one
13
+ * successor pinned to its exact content. Proven by workspace-local-intent.test.js.
14
+ */
15
+
16
+ const crypto = require('crypto');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { AMALGM_DIR } = require('../config');
20
+ const durable = require('./durable');
21
+ const provenance = require('./provenance');
22
+
23
+ const INTENT_CONTRACT = 'repo-local-intent@4';
24
+ const LEGACY_INTENT_CONTRACTS = new Set([
25
+ 'repo-local-intent@2',
26
+ 'repo-local-intent@3',
27
+ ]);
28
+ const SUCCESSOR_CONTRACT = 'repo-local-intent-successor@2';
29
+ const LEGACY_SUCCESSOR_CONTRACT = 'repo-local-intent-successor@1';
30
+ const INTENT_DIR = path.join(AMALGM_DIR, 'state', 'repo-local-intent');
31
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
32
+ const MUTATION_ID = /^[A-Za-z0-9_-]{16,160}$/;
33
+
34
+ function newMutationId() {
35
+ return `mut_${crypto.randomBytes(18).toString('base64url')}`;
36
+ }
37
+
38
+ function intentFile(resourceId) {
39
+ const digest = crypto.createHash('sha256').update(resourceId).digest('hex').slice(0, 24);
40
+ return path.join(INTENT_DIR, `${digest}.json`);
41
+ }
42
+
43
+ function canonicalPath(localPath) {
44
+ const resolved = path.resolve(localPath);
45
+ const tail = [];
46
+ let existing = resolved;
47
+ while (!fs.existsSync(existing)) {
48
+ const parent = path.dirname(existing);
49
+ if (parent === existing) return resolved;
50
+ tail.unshift(path.basename(existing));
51
+ existing = parent;
52
+ }
53
+ return path.join(fs.realpathSync.native(existing), ...tail);
54
+ }
55
+
56
+ function branchState(branches) {
57
+ return (branches || [])
58
+ .map((branch) => ({ name: branch.name, oid: branch.oid ?? null }))
59
+ .sort((left, right) => left.name.localeCompare(right.name));
60
+ }
61
+
62
+ function cardState(card) {
63
+ return {
64
+ head: {
65
+ branch: card?.head?.branch ?? null,
66
+ oid: card?.head?.oid ?? null,
67
+ detached: Boolean(card?.head?.detached),
68
+ },
69
+ branches: branchState(card?.branches || card?.refs),
70
+ };
71
+ }
72
+
73
+ function semanticState(value) {
74
+ return {
75
+ card: cardState(value?.local?.card),
76
+ checkpoint: {
77
+ baseOid: value?.local?.checkpoint?.baseOid ?? null,
78
+ branch: value?.local?.checkpoint?.branch ?? null,
79
+ revision: value?.local?.checkpoint?.value?.revision ?? null,
80
+ },
81
+ };
82
+ }
83
+
84
+ function semanticDigest(value) {
85
+ return crypto.createHash('sha256')
86
+ .update(JSON.stringify(semanticState(value)))
87
+ .digest('hex');
88
+ }
89
+
90
+ function validTree(value) {
91
+ return SHA.test(String(value || ''));
92
+ }
93
+
94
+ function validInitialBase(base) {
95
+ return Boolean(
96
+ base?.through === 0
97
+ && base.card === null
98
+ && validTree(base.indexTree)
99
+ && base.indexTree === base.worktreeTree
100
+ );
101
+ }
102
+
103
+ function validLocal(local) {
104
+ return Boolean(
105
+ validTree(local?.card?.head?.oid)
106
+ && Array.isArray(local?.card?.branches)
107
+ && validTree(local?.indexTree)
108
+ && validTree(local?.worktreeTree)
109
+ && local?.checkpoint?.baseOid === local.card.head.oid
110
+ && typeof local?.physicalDigest === 'string'
111
+ );
112
+ }
113
+
114
+ function sameCard(left, right) {
115
+ return JSON.stringify(cardState(left)) === JSON.stringify(cardState(right));
116
+ }
117
+
118
+ function validSuccessor(value, predecessor) {
119
+ const currentContract = value?.contract === SUCCESSOR_CONTRACT;
120
+ const legacyContract = value?.contract === LEGACY_SUCCESSOR_CONTRACT;
121
+ return Boolean(
122
+ value
123
+ && (currentContract || legacyContract)
124
+ && (!currentContract || MUTATION_ID.test(value.birthMutationId))
125
+ && value.predecessorSemanticDigest === predecessor.semanticDigest
126
+ && value.base?.semanticDigest === predecessor.semanticDigest
127
+ && sameCard(value.base?.card, predecessor.local.card)
128
+ && value.base?.indexTree === predecessor.local.indexTree
129
+ && value.base?.worktreeTree === predecessor.local.worktreeTree
130
+ && value.base?.physicalDigest === predecessor.local.physicalDigest
131
+ && validLocal(value.local)
132
+ && value.semanticDigest === semanticDigest(value)
133
+ && typeof value.capturedAt === 'string'
134
+ );
135
+ }
136
+
137
+ function validIntent(value, project) {
138
+ const initial = validInitialBase(value?.base);
139
+ const currentContract = value?.contract === INTENT_CONTRACT;
140
+ const legacyContract = LEGACY_INTENT_CONTRACTS.has(value?.contract)
141
+ && (
142
+ value.contract !== 'repo-local-intent@2'
143
+ || value.successor === undefined
144
+ || value.successor === null
145
+ );
146
+ return Boolean(
147
+ value
148
+ && (currentContract || legacyContract)
149
+ && (!currentContract || MUTATION_ID.test(value.birthMutationId))
150
+ && value.resourceId === project.resourceId
151
+ && value.ownerUserId === project.ownerUserId
152
+ && canonicalPath(value.path) === canonicalPath(project.path)
153
+ && Number.isInteger(value.base?.through)
154
+ && value.base.through >= 0
155
+ && (
156
+ initial
157
+ || (
158
+ validTree(value.base?.card?.head?.oid)
159
+ && Array.isArray(value.base?.card?.branches)
160
+ && validTree(value.base?.indexTree)
161
+ && validTree(value.base?.worktreeTree)
162
+ )
163
+ )
164
+ && validLocal(value.local)
165
+ && value.semanticDigest === semanticDigest(value)
166
+ && (
167
+ value.successor === undefined
168
+ || value.successor === null
169
+ || validSuccessor(value.successor, value)
170
+ )
171
+ && (
172
+ value.publication === null
173
+ || (
174
+ typeof value.publication?.mutationId === 'string'
175
+ && (
176
+ !currentContract
177
+ || value.publication.mutationId === value.birthMutationId
178
+ )
179
+ && value.publication.semanticDigest === value.semanticDigest
180
+ && value.publication.baseThrough === value.base.through
181
+ && (
182
+ (
183
+ value.publication.numberedVersion === null
184
+ && value.publication.numberedAt === null
185
+ && value.publication.foldedAt === null
186
+ )
187
+ || (
188
+ Number.isInteger(value.publication.numberedVersion)
189
+ && value.publication.numberedVersion > 0
190
+ && typeof value.publication.numberedAt === 'string'
191
+ && (
192
+ value.publication.foldedAt === null
193
+ || typeof value.publication.foldedAt === 'string'
194
+ )
195
+ )
196
+ )
197
+ )
198
+ )
199
+ );
200
+ }
201
+
202
+ function persistIntent(project, value) {
203
+ if (!validIntent(value, project)) throw new Error('local code intent is invalid');
204
+ durable.writeJson(intentFile(project.resourceId), value, { pretty: true });
205
+ return value;
206
+ }
207
+
208
+ function readIntent(project) {
209
+ if (!project?.resourceId || !project?.path || !project?.ownerUserId) return null;
210
+ const file = intentFile(project.resourceId);
211
+ let raw;
212
+ try {
213
+ raw = fs.readFileSync(file, 'utf8');
214
+ } catch (error) {
215
+ if (error?.code === 'ENOENT') return null;
216
+ throw new Error(`cannot read local code intent ${file}`, { cause: error });
217
+ }
218
+ let value;
219
+ try {
220
+ value = JSON.parse(raw);
221
+ } catch (error) {
222
+ throw new Error(`local code intent ${file} is not valid JSON`, { cause: error });
223
+ }
224
+ if (!validIntent(value, project)) {
225
+ throw new Error(`local code intent ${file} has an invalid or unsupported contract`);
226
+ }
227
+ return value;
228
+ }
229
+
230
+ function writeIntent(project, input) {
231
+ const held = readIntent(project);
232
+ const nextSemanticDigest = semanticDigest({ local: input.local });
233
+ const samePredecessor = held
234
+ && held.semanticDigest === nextSemanticDigest
235
+ && JSON.stringify(held.base) === JSON.stringify(input.base);
236
+ let birthMutationId = MUTATION_ID.test(String(input.birthMutationId || ''))
237
+ ? input.birthMutationId
238
+ : null;
239
+ if (
240
+ !birthMutationId
241
+ && (
242
+ input.preserveBirthMutationId === true
243
+ || samePredecessor
244
+ )
245
+ && MUTATION_ID.test(String(held?.birthMutationId || ''))
246
+ ) birthMutationId = held.birthMutationId;
247
+ if (
248
+ !birthMutationId
249
+ && MUTATION_ID.test(String(input.publication?.mutationId || ''))
250
+ ) birthMutationId = input.publication.mutationId;
251
+ if (!birthMutationId) birthMutationId = newMutationId();
252
+ const next = {
253
+ contract: INTENT_CONTRACT,
254
+ birthMutationId,
255
+ resourceId: project.resourceId,
256
+ ownerUserId: project.ownerUserId,
257
+ path: canonicalPath(project.path),
258
+ base: input.base,
259
+ local: input.local,
260
+ publication: input.publication || null,
261
+ terminal: input.terminal || null,
262
+ capturedAt: new Date().toISOString(),
263
+ };
264
+ next.semanticDigest = nextSemanticDigest;
265
+ next.successor = input.successor === undefined && samePredecessor
266
+ ? held.successor || null
267
+ : input.successor || null;
268
+ return persistIntent(project, next);
269
+ }
270
+
271
+ /**
272
+ * Freeze the published intent and atomically replace only its open successor.
273
+ *
274
+ * A successor is a final desk state, not an edit log. Its base is the exact
275
+ * content identity of the frozen predecessor, so repeated saves coalesce
276
+ * without ever changing the bytes or publication identity already in flight.
277
+ */
278
+ function writeSuccessor(project, predecessor, input) {
279
+ let held = readIntent(project);
280
+ if (!held || held.semanticDigest !== predecessor?.semanticDigest) return null;
281
+ if (
282
+ held.contract !== INTENT_CONTRACT
283
+ || !MUTATION_ID.test(String(held.birthMutationId || ''))
284
+ ) {
285
+ held = writeIntent(project, {
286
+ base: held.base,
287
+ local: held.local,
288
+ terminal: held.terminal,
289
+ successor: held.successor,
290
+ publication: held.publication,
291
+ });
292
+ }
293
+ const prior = held.successor || null;
294
+ const successor = {
295
+ contract: SUCCESSOR_CONTRACT,
296
+ birthMutationId: null,
297
+ predecessorSemanticDigest: held.semanticDigest,
298
+ base: {
299
+ semanticDigest: held.semanticDigest,
300
+ card: held.local.card,
301
+ indexTree: held.local.indexTree,
302
+ worktreeTree: held.local.worktreeTree,
303
+ physicalDigest: held.local.physicalDigest,
304
+ },
305
+ local: input.local,
306
+ terminal: input.terminal || null,
307
+ capturedAt: new Date().toISOString(),
308
+ };
309
+ successor.semanticDigest = semanticDigest(successor);
310
+ successor.birthMutationId = (
311
+ prior?.semanticDigest === successor.semanticDigest
312
+ && MUTATION_ID.test(String(prior.birthMutationId || ''))
313
+ ) ? prior.birthMutationId : newMutationId();
314
+ persistIntent(project, { ...held, contract: INTENT_CONTRACT, successor });
315
+ return successor;
316
+ }
317
+
318
+ function readSuccessor(project) {
319
+ return readIntent(project)?.successor || null;
320
+ }
321
+
322
+ function clearSuccessor(project, predecessor = null) {
323
+ let held = readIntent(project);
324
+ if (
325
+ !held?.successor
326
+ || (
327
+ predecessor
328
+ && held.semanticDigest !== predecessor.semanticDigest
329
+ )
330
+ ) return false;
331
+ if (
332
+ held.contract !== INTENT_CONTRACT
333
+ || !MUTATION_ID.test(String(held.birthMutationId || ''))
334
+ ) {
335
+ held = writeIntent(project, {
336
+ base: held.base,
337
+ local: held.local,
338
+ terminal: held.terminal,
339
+ successor: held.successor,
340
+ publication: held.publication,
341
+ });
342
+ }
343
+ persistIntent(project, { ...held, contract: INTENT_CONTRACT, successor: null });
344
+ return true;
345
+ }
346
+
347
+ function clearIntent(project) {
348
+ if (!project?.resourceId) return false;
349
+ const file = intentFile(project.resourceId);
350
+ if (!fs.existsSync(file)) return false;
351
+ fs.rmSync(file, { force: true });
352
+ return true;
353
+ }
354
+
355
+ function stampPublication(project, intent, mutationId) {
356
+ const held = readIntent(project);
357
+ if (
358
+ !held
359
+ || held.semanticDigest !== intent?.semanticDigest
360
+ || mutationId !== held.birthMutationId
361
+ ) return null;
362
+ return writeIntent(project, {
363
+ birthMutationId: held.birthMutationId,
364
+ base: held.base,
365
+ local: held.local,
366
+ terminal: held.terminal,
367
+ publication: {
368
+ mutationId,
369
+ semanticDigest: held.semanticDigest,
370
+ baseThrough: held.base.through,
371
+ numberedVersion: null,
372
+ numberedAt: null,
373
+ foldedAt: null,
374
+ },
375
+ });
376
+ }
377
+
378
+ function reservePublication(project, intent) {
379
+ let held = readIntent(project);
380
+ if (!held || held.semanticDigest !== intent?.semanticDigest) return null;
381
+ if (!MUTATION_ID.test(String(held.birthMutationId || ''))) {
382
+ held = writeIntent(project, {
383
+ base: held.base,
384
+ local: held.local,
385
+ terminal: held.terminal,
386
+ successor: held.successor,
387
+ publication: held.publication,
388
+ });
389
+ }
390
+ if (
391
+ held.publication
392
+ && held.publication.semanticDigest === held.semanticDigest
393
+ && held.publication.baseThrough === held.base.through
394
+ && held.publication.numberedVersion === null
395
+ ) return held;
396
+ provenance.requireTransition(
397
+ provenance.publicationState(held.publication),
398
+ provenance.ACTION.RESERVE,
399
+ provenance.STATE.RESERVED,
400
+ ['intent-record', 'publication-id'],
401
+ );
402
+ return stampPublication(
403
+ project,
404
+ held,
405
+ held.birthMutationId,
406
+ );
407
+ }
408
+
409
+ function markPublicationNumbered(project, mutationId, version, numberedAt = null) {
410
+ const held = readIntent(project);
411
+ if (!held || held.publication?.mutationId !== mutationId) return null;
412
+ if (!Number.isInteger(version) || version < 1) {
413
+ throw new Error('numbered publication version is invalid');
414
+ }
415
+ if (
416
+ held.publication.numberedVersion !== null
417
+ && held.publication.numberedVersion !== version
418
+ ) throw new Error('publication received a different official number');
419
+ if (held.publication.numberedVersion === version) return held;
420
+ provenance.requireTransition(
421
+ provenance.publicationState(held.publication),
422
+ provenance.ACTION.NUMBER,
423
+ provenance.STATE.NUMBERED,
424
+ ['publication-id', 'official-version', 'server-receipt'],
425
+ );
426
+ return writeIntent(project, {
427
+ base: held.base,
428
+ local: held.local,
429
+ terminal: held.terminal,
430
+ publication: {
431
+ ...held.publication,
432
+ numberedVersion: version,
433
+ numberedAt: numberedAt || new Date().toISOString(),
434
+ },
435
+ });
436
+ }
437
+
438
+ function markPublicationFolded(project, mutationId, version, numberedAt = null) {
439
+ const numbered = markPublicationNumbered(project, mutationId, version, numberedAt);
440
+ if (!numbered || numbered.publication.foldedAt !== null) return numbered;
441
+ provenance.requireTransition(
442
+ provenance.publicationState(numbered.publication),
443
+ provenance.ACTION.FOLD,
444
+ provenance.STATE.FOLDED,
445
+ ['predecessor-folded', 'official-event', 'official-snapshot'],
446
+ );
447
+ return writeIntent(project, {
448
+ base: numbered.base,
449
+ local: numbered.local,
450
+ terminal: numbered.terminal,
451
+ publication: {
452
+ ...numbered.publication,
453
+ foldedAt: new Date().toISOString(),
454
+ },
455
+ });
456
+ }
457
+
458
+ function officialAcknowledgement(project, intent) {
459
+ if (!intent?.publication?.mutationId) return null;
460
+ if (
461
+ Number.isInteger(intent.publication.numberedVersion)
462
+ && intent.publication.numberedVersion > 0
463
+ && typeof intent.publication.numberedAt === 'string'
464
+ ) {
465
+ return {
466
+ mutationId: intent.publication.mutationId,
467
+ version: intent.publication.numberedVersion,
468
+ };
469
+ }
470
+ const row = require('../state/mutations').getMutation(
471
+ `resource:${project.resourceId}`,
472
+ intent.publication.mutationId,
473
+ { revealOperation: true },
474
+ );
475
+ return (
476
+ row
477
+ && row.state === 'synced'
478
+ && Number.isInteger(row.officialCloudVersion)
479
+ && row.officialCloudVersion > 0
480
+ && intent.publication.semanticDigest === intent.semanticDigest
481
+ ) ? {
482
+ mutationId: row.mutationId,
483
+ version: row.officialCloudVersion,
484
+ } : null;
485
+ }
486
+
487
+ function publicationMayRebase(project, intent, through) {
488
+ if (
489
+ !intent?.publication?.mutationId
490
+ || intent.publication.numberedVersion !== null
491
+ || !Number.isInteger(through)
492
+ || through <= intent.base.through
493
+ ) return false;
494
+ const row = require('../state/mutations').getMutation(
495
+ `resource:${project.resourceId}`,
496
+ intent.publication.mutationId,
497
+ );
498
+ return !row || (
499
+ row.state === 'rejected'
500
+ && row.failureCode === 'code_project_stale_base'
501
+ );
502
+ }
503
+
504
+ function readOwnedIntent(project) {
505
+ const intent = readIntent(project);
506
+ if (!intent) return null;
507
+ return officialAcknowledgement(project, intent) ? null : intent;
508
+ }
509
+
510
+ module.exports = {
511
+ INTENT_CONTRACT,
512
+ SUCCESSOR_CONTRACT,
513
+ _test: {
514
+ canonicalPath,
515
+ cardState,
516
+ semanticDigest,
517
+ validInitialBase,
518
+ validIntent,
519
+ validSuccessor,
520
+ },
521
+ clearIntent,
522
+ clearSuccessor,
523
+ intentFile,
524
+ markPublicationFolded,
525
+ markPublicationNumbered,
526
+ officialAcknowledgement,
527
+ publicationMayRebase,
528
+ readIntent,
529
+ readOwnedIntent,
530
+ readSuccessor,
531
+ reservePublication,
532
+ stampPublication,
533
+ writeIntent,
534
+ writeSuccessor,
535
+ };
@@ -72,6 +72,15 @@ async function hasTree(worktreeRoot, treeOid) {
72
72
  return result.exitCode === 0;
73
73
  }
74
74
 
75
+ /** Gitlinks are a different repository and are not a file state we can apply. */
76
+ async function gitlinksInTree(worktreeRoot, treeOid) {
77
+ const { stdout } = await run(['ls-tree', '-r', '-z', treeOid], worktreeRoot);
78
+ return stdout.split('\0').filter(Boolean).flatMap((entry) => {
79
+ const match = entry.match(/^160000 commit [0-9a-f]+\t(.+)$/);
80
+ return match ? [match[1]] : [];
81
+ });
82
+ }
83
+
75
84
  /**
76
85
  * The tree a captured worktree patch describes: base commit's tree with
77
86
  * the patch applied. Computed entirely in a scratch index — the worktree
@@ -135,7 +144,7 @@ async function readBlob(worktreeRoot, oid) {
135
144
 
136
145
  /** The working file as bytes: { kind: 'file'|'symlink', content, exec } or null. */
137
146
  function workFileState(worktreeRoot, relPath) {
138
- const abs = path.join(worktreeRoot, relPath);
147
+ const abs = transition.confinedPath(worktreeRoot, relPath);
139
148
  let stat;
140
149
  try {
141
150
  stat = fs.lstatSync(abs);
@@ -145,7 +154,7 @@ function workFileState(worktreeRoot, relPath) {
145
154
  if (stat.isSymbolicLink()) {
146
155
  return { kind: 'symlink', content: Buffer.from(fs.readlinkSync(abs)), exec: false };
147
156
  }
148
- if (!stat.isFile()) return null;
157
+ if (!stat.isFile()) return { kind: 'other' };
149
158
  return { kind: 'file', content: fs.readFileSync(abs), exec: (stat.mode & 0o100) !== 0 };
150
159
  }
151
160
 
@@ -168,7 +177,11 @@ async function reduceTree(worktreeRoot, currentTree, baseTree, targetTree) {
168
177
  const updates = [];
169
178
  for (const relPath of await changedPaths(worktreeRoot, baseTree, targetTree)) {
170
179
  if (unsafePath(relPath)) throw new Error(`unsafe path: ${relPath}`);
171
- updates.push({ path: relPath, entry: await entryAt(worktreeRoot, targetTree, relPath) });
180
+ const entry = await entryAt(worktreeRoot, targetTree, relPath);
181
+ if (entry && entry.type !== 'blob') {
182
+ throw new Error(`unsupported gitlink/submodule at ${relPath}`);
183
+ }
184
+ updates.push({ path: relPath, entry });
172
185
  }
173
186
 
174
187
  return {
@@ -176,6 +189,66 @@ async function reduceTree(worktreeRoot, currentTree, baseTree, targetTree) {
176
189
  };
177
190
  }
178
191
 
192
+ function sameEntry(left, right) {
193
+ return (
194
+ left === null
195
+ ? right === null
196
+ : right !== null
197
+ && left.mode === right.mode
198
+ && left.type === right.type
199
+ && left.oid === right.oid
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Resolve a crash-time tree from an exact apply WAL.
205
+ *
206
+ * Values equal to WAL source or target are known official progress and land
207
+ * on target. A value equal to neither is a real write made after the WAL was
208
+ * sealed, so it remains on top of target.
209
+ */
210
+ async function reconcileApplyingTree(
211
+ worktreeRoot,
212
+ currentTree,
213
+ sourceTree,
214
+ targetTree,
215
+ { sourceCrossed = false, crossedPaths = null } = {},
216
+ ) {
217
+ const paths = new Set([
218
+ ...await changedPaths(worktreeRoot, sourceTree, currentTree),
219
+ ...await changedPaths(worktreeRoot, targetTree, currentTree),
220
+ ]);
221
+ const local = [];
222
+ const updates = [];
223
+ for (const relPath of [...paths].sort()) {
224
+ if (unsafePath(relPath)) throw new Error(`unsafe path: ${relPath}`);
225
+ const [current, source, target] = await Promise.all([
226
+ entryAt(worktreeRoot, currentTree, relPath),
227
+ entryAt(worktreeRoot, sourceTree, relPath),
228
+ entryAt(worktreeRoot, targetTree, relPath),
229
+ ]);
230
+ for (const entry of [current, source, target]) {
231
+ if (entry && entry.type !== 'blob') {
232
+ throw new Error(`unsupported gitlink/submodule at ${relPath}`);
233
+ }
234
+ }
235
+ if (sameEntry(current, target)) continue;
236
+ const crossed = sourceCrossed || crossedPaths?.has(relPath) === true;
237
+ // Endpoint resemblance alone is not provenance. If the apply receipt
238
+ // proves this path crossed, returning to the source value is a later
239
+ // local write (including deleting an official create) and must remain.
240
+ if (sameEntry(current, source) && !crossed) continue;
241
+ local.push(relPath);
242
+ updates.push({ path: relPath, entry: current });
243
+ }
244
+ return {
245
+ tree: updates.length
246
+ ? await updateHeldTree(worktreeRoot, targetTree, updates)
247
+ : targetTree,
248
+ local,
249
+ };
250
+ }
251
+
179
252
  /** heldTree with the given entries replaced/removed — via a scratch index. */
180
253
  async function updateHeldTree(worktreeRoot, heldTree, applied) {
181
254
  return withScratch(async (dir) => {
@@ -214,15 +287,22 @@ async function advance(worktreeRoot, heldTree, targetTree, { label } = {}) {
214
287
  if (unsafePath(relPath)) throw new Error(`unsafe path: ${relPath}`);
215
288
  const held = await entryAt(worktreeRoot, heldTree, relPath);
216
289
  const target = await entryAt(worktreeRoot, targetTree, relPath);
217
- if ((held && held.type !== 'blob') || (target && target.type !== 'blob')) continue;
290
+ if ((held && held.type !== 'blob') || (target && target.type !== 'blob')) {
291
+ throw new Error(`unsupported gitlink/submodule at ${relPath}`);
292
+ }
218
293
  const heldBytes = held ? await readBlob(worktreeRoot, held.oid) : null;
219
294
  const targetBytes = target ? await readBlob(worktreeRoot, target.oid) : null;
220
295
  const work = workFileState(worktreeRoot, relPath);
221
296
 
222
297
  if (workMatches(work, held, heldBytes)) {
223
298
  writes.push(target
224
- ? { path: relPath, content: targetBytes, mode: target.mode }
225
- : { path: relPath, content: null });
299
+ ? {
300
+ path: relPath,
301
+ content: targetBytes,
302
+ mode: target.mode,
303
+ expected: work,
304
+ }
305
+ : { path: relPath, content: null, expected: work });
226
306
  }
227
307
  applied.push({ path: relPath, entry: target });
228
308
  }
@@ -238,9 +318,12 @@ async function advance(worktreeRoot, heldTree, targetTree, { label } = {}) {
238
318
 
239
319
  module.exports = {
240
320
  advance,
321
+ changedPaths,
241
322
  diffOfTree,
323
+ gitlinksInTree,
242
324
  hasTree,
243
325
  indexTree,
326
+ reconcileApplyingTree,
244
327
  reduceTree,
245
328
  treeOf,
246
329
  treeOfPatch,