amalgm 0.1.239 → 0.1.241
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/lib/shared-realtime-tunnel.js +18 -4
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
|
@@ -545,9 +545,13 @@ function createSharedRealtimeTunnel({
|
|
|
545
545
|
void localStateJson('POST', '/state/shared/attachments/fail', {
|
|
546
546
|
attachmentId: identity.attachmentId,
|
|
547
547
|
message: error?.message || 'local snapshot materialization failed',
|
|
548
|
-
|
|
548
|
+
// The gateway already authorized and completed this attachment.
|
|
549
|
+
// A local materialization failure is weather (busy disk, concurrent
|
|
550
|
+
// save, restart), never proof that the binding is permanently bad.
|
|
551
|
+
retryable: true,
|
|
549
552
|
}).catch(() => {});
|
|
550
553
|
attachmentInflight.delete(frame.request_id);
|
|
554
|
+
scheduleAttachmentDrain(RETRY_MS);
|
|
551
555
|
});
|
|
552
556
|
}
|
|
553
557
|
|
|
@@ -614,9 +618,18 @@ function createSharedRealtimeTunnel({
|
|
|
614
618
|
}
|
|
615
619
|
|
|
616
620
|
function handleError(frame) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
621
|
+
const failedUpload = repoUploadInflight.get(frame.request_id);
|
|
622
|
+
if (failedUpload) {
|
|
623
|
+
void localStateJson('POST', '/state/repo-objects/uploads/fail', {
|
|
624
|
+
resourceId: failedUpload.resourceId,
|
|
625
|
+
sha256: failedUpload.sha256,
|
|
626
|
+
retryable: frame.retryable !== false,
|
|
627
|
+
code: frame.code,
|
|
628
|
+
message: frame.message,
|
|
629
|
+
}).finally(() => {
|
|
630
|
+
repoUploadInflight.delete(frame.request_id);
|
|
631
|
+
scheduleRepoObjects();
|
|
632
|
+
});
|
|
620
633
|
return;
|
|
621
634
|
}
|
|
622
635
|
if (repoDownloadInflight.has(frame.request_id)) {
|
|
@@ -697,6 +710,7 @@ function createSharedRealtimeTunnel({
|
|
|
697
710
|
void localStateJson('POST', '/state/mutations/fail', {
|
|
698
711
|
resourceId: identity.resourceId,
|
|
699
712
|
mutationId: identity.mutationId,
|
|
713
|
+
code: frame.code,
|
|
700
714
|
message: `${frame.code || 'shared_error'}: ${frame.message || 'shared mutation failed'}`,
|
|
701
715
|
retryable: frame.retryable !== false,
|
|
702
716
|
}).finally(() => {
|
package/package.json
CHANGED
|
@@ -599,18 +599,26 @@ function getCookieCloudBootstrap(options = {}) {
|
|
|
599
599
|
|
|
600
600
|
function installCookieCloudBootstrap(input = {}, options = {}) {
|
|
601
601
|
const database = options.database || require('../state/db').openLocalDb();
|
|
602
|
-
const
|
|
603
|
-
|
|
602
|
+
const replicas = require('../state/replicas');
|
|
603
|
+
const envelope = replicas.validateSnapshotEnvelope(input, {
|
|
604
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
605
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
606
|
+
});
|
|
607
|
+
const expectedResourceId = privateCookieResourceId(
|
|
608
|
+
require('../workspace/identity').userId(),
|
|
609
|
+
);
|
|
610
|
+
if (!expectedResourceId || envelope.resourceId !== expectedResourceId) {
|
|
604
611
|
throw new Error('Private cookie resource does not belong to this runtime user');
|
|
605
612
|
}
|
|
606
|
-
if (input.contract !== COOKIE_CLOUD_CONTRACT || Number(input.schemaVersion) !== COOKIE_JAR_SCHEMA_VERSION) {
|
|
607
|
-
throw new Error('Private cookie resource contract changed');
|
|
608
|
-
}
|
|
609
613
|
const bytes = Buffer.from(String(input.snapshotBase64 || ''), 'base64');
|
|
610
614
|
const checksum = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
611
615
|
if (checksum !== input.snapshotChecksum) throw new Error('Private cookie snapshot checksum mismatch');
|
|
612
616
|
const local = loadInternalCookieJar();
|
|
613
617
|
const remote = normalizeCloudSnapshot(JSON.parse(bytes.toString('utf8')));
|
|
618
|
+
if (remote.version !== envelope.snapshotVersion) {
|
|
619
|
+
throw new Error('Private cookie snapshot version mismatch');
|
|
620
|
+
}
|
|
621
|
+
const replica = cookieCloudReplica({ database });
|
|
614
622
|
const pending = require('../state/mutations').listPendingCloudMutations(replica.resourceId, { database });
|
|
615
623
|
const pendingKeys = new Set(pending.map((mutation) => mutation.operation?.key).filter(Boolean));
|
|
616
624
|
// A base snapshot behind the official head is not the current cloud jar.
|
|
@@ -618,28 +626,62 @@ function installCookieCloudBootstrap(input = {}, options = {}) {
|
|
|
618
626
|
// as pre-cloud work and create an echo loop on every reconnect. The gateway
|
|
619
627
|
// normally compacts private resources to head before bootstrap; this guard
|
|
620
628
|
// makes the runtime fail closed if an older gateway sends a stale base.
|
|
621
|
-
const snapshotIsCurrent =
|
|
629
|
+
const snapshotIsCurrent = envelope.snapshotVersion === envelope.headVersion;
|
|
622
630
|
const bootstrapOperations = snapshotIsCurrent
|
|
623
631
|
? localBootstrapOperations(local, remote, pendingKeys)
|
|
624
632
|
: [];
|
|
625
633
|
|
|
634
|
+
if (!snapshotIsCurrent) {
|
|
635
|
+
// A stale prefix is useful only as an identity check for a prefix this
|
|
636
|
+
// machine has already applied. Advancing to a newer stale prefix without
|
|
637
|
+
// installing its bytes would make the cursor lie; installing it would
|
|
638
|
+
// overwrite local legacy state before a head snapshot can classify it.
|
|
639
|
+
const currentReplica = replicas.refreshReplicaAuthority({
|
|
640
|
+
resourceId: replica.resourceId,
|
|
641
|
+
authorityEpoch: envelope.authorityEpoch,
|
|
642
|
+
headVersion: envelope.headVersion,
|
|
643
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
644
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
645
|
+
}, { database });
|
|
646
|
+
return {
|
|
647
|
+
replica: currentReplica,
|
|
648
|
+
jar: readCookieJar(),
|
|
649
|
+
bootstrappedMutations: 0,
|
|
650
|
+
bootstrapDeferred: true,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Legacy local values become durable mutations before the official
|
|
655
|
+
// snapshot can replace the physical jar. If the process dies after the
|
|
656
|
+
// replacement, restart can replay these rows; in-memory bootstrapOperations
|
|
657
|
+
// are never the only copy of user state.
|
|
658
|
+
let currentReplica = replica;
|
|
659
|
+
for (const operation of bootstrapOperations) {
|
|
660
|
+
commitCookieOperation(operation, currentReplica, { database });
|
|
661
|
+
currentReplica = cookieCloudReplica({ database });
|
|
662
|
+
}
|
|
663
|
+
const durablePending = require('../state/mutations')
|
|
664
|
+
.listPendingCloudMutations(replica.resourceId, { database });
|
|
665
|
+
|
|
626
666
|
persistCloudSnapshot({
|
|
627
667
|
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
628
668
|
version: remote.version,
|
|
629
669
|
records: [...remote.records.values()],
|
|
630
670
|
tombstones: [...remote.tombstones.values()],
|
|
631
671
|
});
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
672
|
+
if (options.testHooks?.crashAfterSnapshotPersist === true) {
|
|
673
|
+
throw new Error('simulated cookie bootstrap crash after snapshot persist');
|
|
674
|
+
}
|
|
675
|
+
replicas.advanceReplicaSnapshot({
|
|
676
|
+
resourceId: replica.resourceId,
|
|
677
|
+
snapshotVersion: envelope.snapshotVersion,
|
|
678
|
+
snapshotChecksum: checksum,
|
|
679
|
+
authorityEpoch: envelope.authorityEpoch,
|
|
638
680
|
}, { database });
|
|
639
|
-
refreshReplicaAuthority({
|
|
681
|
+
replicas.refreshReplicaAuthority({
|
|
640
682
|
resourceId: replica.resourceId,
|
|
641
|
-
authorityEpoch:
|
|
642
|
-
headVersion:
|
|
683
|
+
authorityEpoch: envelope.authorityEpoch,
|
|
684
|
+
headVersion: envelope.headVersion,
|
|
643
685
|
contract: COOKIE_CLOUD_CONTRACT,
|
|
644
686
|
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
645
687
|
}, { database });
|
|
@@ -647,12 +689,18 @@ function installCookieCloudBootstrap(input = {}, options = {}) {
|
|
|
647
689
|
// Snapshot installation never discards local work. Already-journaled edits
|
|
648
690
|
// replay from their durable rows; pre-cloud legacy entries become ordinary
|
|
649
691
|
// new mutations through the same commit path.
|
|
650
|
-
for (const mutation of
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
692
|
+
for (const mutation of durablePending) {
|
|
693
|
+
materializeCookieOperation(mutation.operation);
|
|
694
|
+
if (mutation.state === 'saving-local' && mutation.materializedAt === null) {
|
|
695
|
+
require('../state/mutations').completeMaterializedMutation(
|
|
696
|
+
mutation.channelId,
|
|
697
|
+
mutation.mutationId,
|
|
698
|
+
cookieEvent(mutation.operation, mutation.mutationId),
|
|
699
|
+
{ database },
|
|
700
|
+
);
|
|
701
|
+
}
|
|
655
702
|
}
|
|
703
|
+
currentReplica = cookieCloudReplica({ database });
|
|
656
704
|
return {
|
|
657
705
|
replica: currentReplica,
|
|
658
706
|
jar: readCookieJar(),
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* system-instructions.md global user-authored instructions under AMALGM_DIR
|
|
17
17
|
*
|
|
18
18
|
* Direct disk edits (editors, agents with their own file tools) are picked up
|
|
19
|
-
* by the per-project fs watcher
|
|
20
|
-
*
|
|
21
|
-
* events.
|
|
19
|
+
* by the per-project fs watcher, a bounded verifier for notifications the OS
|
|
20
|
+
* may drop, and the fs REST hooks. All three funnel into the same reconcile
|
|
21
|
+
* path. Reconcile is idempotent: unchanged revisions emit no events.
|
|
22
22
|
*
|
|
23
23
|
* Everything that emits events runs in the amalgm-mcp process — Local Live
|
|
24
24
|
* only streams events appended in-process. chat-core (chat-server process)
|
|
@@ -56,11 +56,13 @@ const {
|
|
|
56
56
|
|
|
57
57
|
const RESOURCE = 'project_context';
|
|
58
58
|
const WATCH_DEBOUNCE_MS = 200;
|
|
59
|
+
const WATCH_VERIFY_MS = 5_000;
|
|
59
60
|
// The system scope carries user-authored instructions that apply to every
|
|
60
61
|
// session on this computer. It has no change log or project state.
|
|
61
62
|
const SYSTEM_CONTEXT_ID = 'system';
|
|
62
63
|
|
|
63
|
-
const watchers = new Map(); // projectId -> { watcher, timer, projectPath }
|
|
64
|
+
const watchers = new Map(); // projectId -> { watcher, timer, project, projectPath }
|
|
65
|
+
let watchVerificationTimer = null;
|
|
64
66
|
|
|
65
67
|
function systemProject() {
|
|
66
68
|
return { id: SYSTEM_CONTEXT_ID, path: path.resolve(AMALGM_DIR), name: 'Amalgm System' };
|
|
@@ -683,12 +685,50 @@ function closeWatcher(projectId) {
|
|
|
683
685
|
const entry = watchers.get(projectId);
|
|
684
686
|
if (!entry) return;
|
|
685
687
|
if (entry.timer) clearTimeout(entry.timer);
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
688
|
+
if (entry.watcher) {
|
|
689
|
+
try {
|
|
690
|
+
entry.watcher.close();
|
|
691
|
+
} catch {
|
|
692
|
+
// Already closed.
|
|
693
|
+
}
|
|
690
694
|
}
|
|
691
695
|
watchers.delete(projectId);
|
|
696
|
+
if (watchers.size === 0 && watchVerificationTimer) {
|
|
697
|
+
clearInterval(watchVerificationTimer);
|
|
698
|
+
watchVerificationTimer = null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* `fs.watch` is a latency hint, not durable evidence: kernels may coalesce or
|
|
704
|
+
* drop notifications. This bounded pass owns that wait and makes every
|
|
705
|
+
* watched context converge even when no callback arrives.
|
|
706
|
+
*/
|
|
707
|
+
function verifyProjectContextWatchers() {
|
|
708
|
+
for (const entry of watchers.values()) {
|
|
709
|
+
try {
|
|
710
|
+
reconcileProjectContext(entry.project, { source: 'project-context:verify' });
|
|
711
|
+
if (!entry.watcher) {
|
|
712
|
+
entry.watcher = openProjectContextWatcher(entry.project);
|
|
713
|
+
}
|
|
714
|
+
} catch (error) {
|
|
715
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
716
|
+
console.warn(
|
|
717
|
+
`[ProjectContext] Bounded verify failed for ${entry.projectPath}: ${message}`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function ensureWatchVerification() {
|
|
724
|
+
if (watchVerificationTimer || watchers.size === 0) return;
|
|
725
|
+
watchVerificationTimer = setInterval(
|
|
726
|
+
verifyProjectContextWatchers,
|
|
727
|
+
WATCH_VERIFY_MS,
|
|
728
|
+
);
|
|
729
|
+
if (typeof watchVerificationTimer.unref === 'function') {
|
|
730
|
+
watchVerificationTimer.unref();
|
|
731
|
+
}
|
|
692
732
|
}
|
|
693
733
|
|
|
694
734
|
/**
|
|
@@ -697,13 +737,7 @@ function closeWatcher(projectId) {
|
|
|
697
737
|
* harmless: by the time the debounce fires, disk matches the row and no event
|
|
698
738
|
* is emitted.
|
|
699
739
|
*/
|
|
700
|
-
function
|
|
701
|
-
const project = projectFromInput(input);
|
|
702
|
-
if (!project?.id || !project?.path) return;
|
|
703
|
-
const existing = watchers.get(project.id);
|
|
704
|
-
if (existing?.projectPath === project.path) return;
|
|
705
|
-
if (existing) closeWatcher(project.id);
|
|
706
|
-
|
|
740
|
+
function openProjectContextWatcher(project) {
|
|
707
741
|
const root = contextPathsFor(project).root;
|
|
708
742
|
let watcher;
|
|
709
743
|
try {
|
|
@@ -723,12 +757,46 @@ function watchProjectContext(input) {
|
|
|
723
757
|
if (typeof entry.timer.unref === 'function') entry.timer.unref();
|
|
724
758
|
});
|
|
725
759
|
} catch {
|
|
726
|
-
//
|
|
727
|
-
return;
|
|
760
|
+
// The bounded verifier remains the durable fallback and retries this hint.
|
|
761
|
+
return null;
|
|
728
762
|
}
|
|
729
|
-
watcher.on('error', () =>
|
|
763
|
+
watcher.on('error', () => {
|
|
764
|
+
const entry = watchers.get(project.id);
|
|
765
|
+
if (!entry || entry.watcher !== watcher) return;
|
|
766
|
+
try {
|
|
767
|
+
watcher.close();
|
|
768
|
+
} catch {
|
|
769
|
+
// Already closed.
|
|
770
|
+
}
|
|
771
|
+
entry.watcher = null;
|
|
772
|
+
});
|
|
730
773
|
if (typeof watcher.unref === 'function') watcher.unref();
|
|
731
|
-
|
|
774
|
+
return watcher;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function watchProjectContext(input) {
|
|
778
|
+
const project = projectFromInput(input);
|
|
779
|
+
if (!project?.id || !project?.path) return;
|
|
780
|
+
const existing = watchers.get(project.id);
|
|
781
|
+
if (existing?.projectPath === project.path) {
|
|
782
|
+
existing.project = project;
|
|
783
|
+
if (!existing.watcher) {
|
|
784
|
+
existing.watcher = openProjectContextWatcher(project);
|
|
785
|
+
}
|
|
786
|
+
ensureWatchVerification();
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (existing) closeWatcher(project.id);
|
|
790
|
+
|
|
791
|
+
const entry = {
|
|
792
|
+
watcher: null,
|
|
793
|
+
timer: null,
|
|
794
|
+
project,
|
|
795
|
+
projectPath: project.path,
|
|
796
|
+
};
|
|
797
|
+
watchers.set(project.id, entry);
|
|
798
|
+
entry.watcher = openProjectContextWatcher(project);
|
|
799
|
+
ensureWatchVerification();
|
|
732
800
|
}
|
|
733
801
|
|
|
734
802
|
/** fs REST hook: reconcile when a context doc is touched outside the store. */
|
|
@@ -59,6 +59,19 @@ async function handleStateRoutes(ctx) {
|
|
|
59
59
|
));
|
|
60
60
|
return true;
|
|
61
61
|
}
|
|
62
|
+
if (ctx.pathname === '/state/repo-objects/uploads/fail') {
|
|
63
|
+
const body = await ctx.readJsonBody();
|
|
64
|
+
ctx.sendJson(200, require('../../workspace/objects').failUpload(
|
|
65
|
+
body?.resourceId,
|
|
66
|
+
body?.sha256,
|
|
67
|
+
{
|
|
68
|
+
retryable: body?.retryable,
|
|
69
|
+
code: body?.code,
|
|
70
|
+
message: body?.message,
|
|
71
|
+
},
|
|
72
|
+
));
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
62
75
|
if (ctx.pathname === '/state/repo-objects/downloads/claim') {
|
|
63
76
|
ctx.sendJson(200, { download: require('../../workspace/objects').claimDownload() });
|
|
64
77
|
return true;
|
|
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
|
|
4
4
|
|
|
5
5
|
const { cachedStatement, openLocalDb } = require('./db');
|
|
6
6
|
const { isSharedResourceId } = require('./cloud-resource-id');
|
|
7
|
-
const { appendStateEvent } = require('./events');
|
|
7
|
+
const { appendStateEvent, insertStateEvent } = require('./events');
|
|
8
8
|
|
|
9
9
|
const ATTACHMENT_ID = /^[A-Za-z0-9_-]{16,160}$/;
|
|
10
10
|
|
|
@@ -30,6 +30,10 @@ function normalizeRow(row) {
|
|
|
30
30
|
attemptCount: Number(row.attempt_count),
|
|
31
31
|
nextAttemptAt: row.next_attempt_at || null,
|
|
32
32
|
lastError: row.last_error || null,
|
|
33
|
+
// Legacy requests predate the fence. Their target was required to be
|
|
34
|
+
// empty, so zero is the only conservative boundary: every surviving or
|
|
35
|
+
// newly captured local mutation remains overlay.
|
|
36
|
+
journalCursor: row.journal_cursor == null ? 0 : Number(row.journal_cursor),
|
|
33
37
|
createdAt: row.created_at,
|
|
34
38
|
updatedAt: row.updated_at,
|
|
35
39
|
};
|
|
@@ -54,12 +58,16 @@ function enqueueAttachment(input, options = {}) {
|
|
|
54
58
|
return { attachment, duplicate: true };
|
|
55
59
|
}
|
|
56
60
|
const now = new Date().toISOString();
|
|
61
|
+
const journalCursor = Number(
|
|
62
|
+
cachedStatement(database, 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal')
|
|
63
|
+
.get().cursor,
|
|
64
|
+
);
|
|
57
65
|
try {
|
|
58
66
|
cachedStatement(database, `
|
|
59
67
|
INSERT INTO shared_resource_attachments (
|
|
60
|
-
attachment_id, resource_id, local_path, created_at, updated_at
|
|
61
|
-
) VALUES (?, ?, ?, ?, ?)
|
|
62
|
-
`).run(attachmentId, resourceId, localPath, now, now);
|
|
68
|
+
attachment_id, resource_id, local_path, journal_cursor, created_at, updated_at
|
|
69
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
70
|
+
`).run(attachmentId, resourceId, localPath, journalCursor, now, now);
|
|
63
71
|
} catch (error) {
|
|
64
72
|
if (String(error?.code || '').includes('CONSTRAINT')) {
|
|
65
73
|
throw invalid('that resource or local path already has an attachment request', 409);
|
|
@@ -70,14 +78,22 @@ function enqueueAttachment(input, options = {}) {
|
|
|
70
78
|
cachedStatement(database, 'SELECT * FROM shared_resource_attachments WHERE attachment_id = ?')
|
|
71
79
|
.get(attachmentId),
|
|
72
80
|
);
|
|
73
|
-
|
|
81
|
+
const eventInput = {
|
|
74
82
|
resource: 'shared:attachments',
|
|
75
83
|
op: 'update',
|
|
76
84
|
id: attachmentId,
|
|
77
85
|
sharedResourceId: resourceId,
|
|
78
86
|
value: { state: 'pending' },
|
|
79
87
|
source: 'shared:attachment',
|
|
80
|
-
}
|
|
88
|
+
};
|
|
89
|
+
if (options.deferEvent === true) {
|
|
90
|
+
return {
|
|
91
|
+
attachment,
|
|
92
|
+
duplicate: false,
|
|
93
|
+
deferredEvent: insertStateEvent(database, eventInput),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
appendStateEvent(eventInput);
|
|
81
97
|
return { attachment, duplicate: false };
|
|
82
98
|
}
|
|
83
99
|
|
|
@@ -129,13 +145,16 @@ function acknowledgeAttachment(attachmentIdInput, payload, options = {}) {
|
|
|
129
145
|
|
|
130
146
|
const installed = payload?.contract === 'workspace-tree@1'
|
|
131
147
|
? require('../workspace/tree-store').installCloudTreeSnapshot(attachment.localPath, payload, { database })
|
|
132
|
-
: payload?.contract === 'code-project-stream@
|
|
148
|
+
: payload?.contract === 'code-project-stream@2'
|
|
133
149
|
? require('../workspace/project-stream').installSnapshot(
|
|
134
150
|
attachment.localPath,
|
|
135
151
|
payload,
|
|
136
152
|
{ database },
|
|
137
153
|
)
|
|
138
|
-
: require('./docs').installCloudDocSnapshot(attachment.localPath,
|
|
154
|
+
: require('./docs').installCloudDocSnapshot(attachment.localPath, {
|
|
155
|
+
...payload,
|
|
156
|
+
attachmentJournalCursor: attachment.journalCursor,
|
|
157
|
+
});
|
|
139
158
|
const now = new Date().toISOString();
|
|
140
159
|
cachedStatement(database, `
|
|
141
160
|
UPDATE shared_resource_attachments
|
|
@@ -200,21 +219,30 @@ function requestRehydration(input, options = {}) {
|
|
|
200
219
|
let attachmentId;
|
|
201
220
|
if (existing) {
|
|
202
221
|
attachmentId = existing.attachment_id;
|
|
222
|
+
const journalCursor = Number(
|
|
223
|
+
cachedStatement(database, 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal')
|
|
224
|
+
.get().cursor,
|
|
225
|
+
);
|
|
203
226
|
cachedStatement(database, `
|
|
204
227
|
UPDATE shared_resource_attachments
|
|
205
228
|
SET attachment_state = 'pending',
|
|
206
229
|
next_attempt_at = NULL,
|
|
207
230
|
last_error = NULL,
|
|
231
|
+
journal_cursor = ?,
|
|
208
232
|
updated_at = ?
|
|
209
233
|
WHERE attachment_id = ?
|
|
210
|
-
`).run(now, attachmentId);
|
|
234
|
+
`).run(journalCursor, now, attachmentId);
|
|
211
235
|
} else {
|
|
212
236
|
attachmentId = `att_${crypto.randomBytes(16).toString('base64url')}`;
|
|
237
|
+
const journalCursor = Number(
|
|
238
|
+
cachedStatement(database, 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal')
|
|
239
|
+
.get().cursor,
|
|
240
|
+
);
|
|
213
241
|
cachedStatement(database, `
|
|
214
242
|
INSERT INTO shared_resource_attachments (
|
|
215
|
-
attachment_id, resource_id, local_path, created_at, updated_at
|
|
216
|
-
) VALUES (?, ?, ?, ?, ?)
|
|
217
|
-
`).run(attachmentId, resourceId, replica.localPath, now, now);
|
|
243
|
+
attachment_id, resource_id, local_path, journal_cursor, created_at, updated_at
|
|
244
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
245
|
+
`).run(attachmentId, resourceId, replica.localPath, journalCursor, now, now);
|
|
218
246
|
}
|
|
219
247
|
const attachment = normalizeRow(
|
|
220
248
|
cachedStatement(database, 'SELECT * FROM shared_resource_attachments WHERE attachment_id = ?')
|