amalgm 0.1.161 → 0.1.162
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 +30 -1
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
- package/runtime/scripts/amalgm-mcp/index.js +3 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
- package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
- package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
- package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
- package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
- package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
- package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
- package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +666 -0
|
@@ -20,11 +20,11 @@ const {
|
|
|
20
20
|
revealOperation,
|
|
21
21
|
} = require('./mutation-contracts');
|
|
22
22
|
const { trace } = require('./trace');
|
|
23
|
+
const { isCloudResourceId } = require('./cloud-resource-id');
|
|
23
24
|
|
|
24
25
|
const MAX_MUTATION_BYTES = 4 * 1024 * 1024;
|
|
25
26
|
const DEFAULT_CLAIM_LIMIT = 100;
|
|
26
27
|
const DEFAULT_CLAIM_LEASE_MS = 30_000;
|
|
27
|
-
const CLOUD_RESOURCE_ID = /^(?:shr|pvr)_[A-Za-z0-9_-]{16,128}$/;
|
|
28
28
|
const MUTATION_ID = /^[A-Za-z0-9_-]{16,160}$/;
|
|
29
29
|
const OPERATION_KIND = /^[a-z][a-z0-9._:-]{0,63}$/;
|
|
30
30
|
const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
@@ -108,8 +108,8 @@ function normalizeMutationInput(input) {
|
|
|
108
108
|
const sharedResourceId = cloudResourceInput == null
|
|
109
109
|
? null
|
|
110
110
|
: cleanText(cloudResourceInput, 'cloudResourceId', 132);
|
|
111
|
-
if (sharedResourceId && !
|
|
112
|
-
throw invalid('cloudResourceId must be
|
|
111
|
+
if (sharedResourceId && !isCloudResourceId(sharedResourceId)) {
|
|
112
|
+
throw invalid('cloudResourceId must be a node UUID or legacy cloud id');
|
|
113
113
|
}
|
|
114
114
|
if (sharedResourceId && channelId !== `resource:${sharedResourceId}`) {
|
|
115
115
|
throw invalid('a cloud mutation channelId must be resource:<cloudResourceId>');
|
|
@@ -803,7 +803,7 @@ function listMutations(options = {}) {
|
|
|
803
803
|
*/
|
|
804
804
|
function listPendingCloudMutations(resourceIdInput, options = {}) {
|
|
805
805
|
const resourceId = cleanText(resourceIdInput, 'resourceId', 132);
|
|
806
|
-
if (!
|
|
806
|
+
if (!isCloudResourceId(resourceId)) {
|
|
807
807
|
throw invalid('resourceId must be an opaque cloud resource id');
|
|
808
808
|
}
|
|
809
809
|
const database = options.database || openLocalDb();
|
|
@@ -6,8 +6,8 @@ const { cachedStatement, openLocalDb } = require('./db');
|
|
|
6
6
|
const { appendStateEvent } = require('./events');
|
|
7
7
|
const { registerReplica } = require('./replicas');
|
|
8
8
|
const { trace } = require('./trace');
|
|
9
|
+
const { isSharedResourceId } = require('./cloud-resource-id');
|
|
9
10
|
|
|
10
|
-
const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
|
|
11
11
|
const PROMOTION_ID = /^[A-Za-z0-9_-]{16,160}$/;
|
|
12
12
|
|
|
13
13
|
function invalid(message, statusCode = 400) {
|
|
@@ -52,7 +52,7 @@ function enqueuePromotion(input, options = {}) {
|
|
|
52
52
|
const promotionId = requiredText(input?.promotionId, 'promotionId', 160);
|
|
53
53
|
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
54
54
|
if (!PROMOTION_ID.test(promotionId)) throw invalid('promotionId has an invalid format');
|
|
55
|
-
if (!
|
|
55
|
+
if (!isSharedResourceId(resourceId)) throw invalid('resourceId must be a node UUID or legacy shared id');
|
|
56
56
|
const localResource = requiredText(input?.localResource, 'localResource');
|
|
57
57
|
const localPath = requiredText(input?.localPath, 'localPath', 4096);
|
|
58
58
|
const contract = requiredText(input?.contract || 'text-yjs@1', 'contract', 128);
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
const { cachedStatement, openLocalDb } = require('./db');
|
|
12
12
|
const { trace } = require('./trace');
|
|
13
|
+
const { isCloudResourceId } = require('./cloud-resource-id');
|
|
13
14
|
|
|
14
|
-
const CLOUD_RESOURCE_ID = /^(?:shr|pvr)_[A-Za-z0-9_-]{16,128}$/;
|
|
15
15
|
const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
16
16
|
|
|
17
17
|
function invalid(message, statusCode = 400) {
|
|
@@ -55,8 +55,8 @@ function normalizeReplica(row) {
|
|
|
55
55
|
|
|
56
56
|
function registerReplica(input, options = {}) {
|
|
57
57
|
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
58
|
-
if (!
|
|
59
|
-
throw invalid('resourceId must be
|
|
58
|
+
if (!isCloudResourceId(resourceId)) {
|
|
59
|
+
throw invalid('resourceId must be a node UUID or legacy cloud id');
|
|
60
60
|
}
|
|
61
61
|
const localResource = requiredText(input?.localResource, 'localResource');
|
|
62
62
|
const localPath = requiredText(input?.localPath, 'localPath', 4096);
|
|
@@ -177,8 +177,9 @@ function refreshReplicaAuthority(input, options = {}) {
|
|
|
177
177
|
throw invalid(`shared resource ${resourceId} contract changed`, 409);
|
|
178
178
|
}
|
|
179
179
|
if (authorityEpoch < Number(row.authority_epoch)) {
|
|
180
|
-
return normalizeReplica(row);
|
|
180
|
+
return { ...normalizeReplica(row), authorityChanged: false };
|
|
181
181
|
}
|
|
182
|
+
const authorityChanged = authorityEpoch > Number(row.authority_epoch);
|
|
182
183
|
cachedStatement(database, `
|
|
183
184
|
UPDATE shared_resource_replicas
|
|
184
185
|
SET authority_epoch = ?,
|
|
@@ -190,7 +191,7 @@ function refreshReplicaAuthority(input, options = {}) {
|
|
|
190
191
|
updated_at = ?
|
|
191
192
|
WHERE resource_id = ?
|
|
192
193
|
`).run(authorityEpoch, headVersion, headVersion, new Date().toISOString(), resourceId);
|
|
193
|
-
return getReplicaByResourceId(resourceId, { database });
|
|
194
|
+
return { ...getReplicaByResourceId(resourceId, { database }), authorityChanged };
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
/**
|
|
@@ -71,29 +71,24 @@ async function handleReplicaRehydrate(body, sendJson) {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
async function handleWorkspaceContentDiscover(body, sendJson) {
|
|
75
|
+
try {
|
|
76
|
+
const attachments = require('../workspace/tree-cloud').enqueueWorkspaceContentAttachments(
|
|
77
|
+
body?.workspaceId,
|
|
78
|
+
body?.contentResources,
|
|
79
|
+
);
|
|
80
|
+
sendJson(attachments.length ? 202 : 200, { attachments });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
sendHandlerError(error, sendJson);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
74
86
|
async function handlePromotionStart(body, sendJson) {
|
|
75
87
|
try {
|
|
76
|
-
// One owner per file (workspace-sync.md §1). A file tracked by a
|
|
77
|
-
// repository is git-owned: it syncs through commits on the workspace
|
|
78
|
-
// rail, and it may join the doc rail only via a future explicit
|
|
79
|
-
// ownership handoff — never by ambient promotion.
|
|
80
|
-
if (typeof body?.path === 'string' && body.path) {
|
|
81
|
-
const owner = await require('../workspace/git').gitOwnerOf(body.path);
|
|
82
|
-
if (owner) {
|
|
83
|
-
throw Object.assign(
|
|
84
|
-
new Error(
|
|
85
|
-
`${body.path} is git-owned: tracked by the repository at ${owner.toplevel}. `
|
|
86
|
-
+ 'Repository files sync through git (commits are the sync points), not the doc rail. '
|
|
87
|
-
+ 'If this file truly belongs on the rail, untrack it first (git rm --cached <file>).',
|
|
88
|
-
),
|
|
89
|
-
{ statusCode: 409, code: 'file_git_owned' },
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
88
|
const opened = require('./docs').openDoc(body?.path);
|
|
94
89
|
const result = require('./promotions').enqueuePromotion({
|
|
95
90
|
promotionId: body?.promotionId || `prm_${crypto.randomBytes(16).toString('base64url')}`,
|
|
96
|
-
resourceId: body?.resourceId || `shr_${crypto.randomBytes(18).toString('base64url')}`,
|
|
91
|
+
resourceId: body?.resourceId || opened.nodeId || `shr_${crypto.randomBytes(18).toString('base64url')}`,
|
|
97
92
|
localResource: opened.resource,
|
|
98
93
|
localPath: opened.path,
|
|
99
94
|
contract: body?.contract || 'text-yjs@1',
|
|
@@ -161,15 +156,22 @@ async function handlePromotionFail(body, sendJson) {
|
|
|
161
156
|
|
|
162
157
|
async function handleAttachmentStart(body, sendJson) {
|
|
163
158
|
try {
|
|
164
|
-
const
|
|
165
|
-
|
|
159
|
+
const tree = body?.contract === 'workspace-tree@1';
|
|
160
|
+
const localPath = tree ? require('path').resolve(body?.path || '') : require('./docs').openDoc(body?.path).path;
|
|
161
|
+
if (tree) {
|
|
162
|
+
const stats = fs.statSync(localPath);
|
|
163
|
+
if (!stats.isDirectory() || fs.readdirSync(localPath).length > 0) {
|
|
164
|
+
sendJson(409, { error: 'cloud workspace attachment requires an empty folder' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
} else if (require('./docs').getDocText(localPath).length > 0) {
|
|
166
168
|
sendJson(409, { error: 'cloud attachment requires an empty local text file' });
|
|
167
169
|
return;
|
|
168
170
|
}
|
|
169
171
|
const result = require('./attachments').enqueueAttachment({
|
|
170
172
|
attachmentId: body?.attachmentId || `att_${crypto.randomBytes(16).toString('base64url')}`,
|
|
171
173
|
resourceId: body?.resourceId,
|
|
172
|
-
localPath
|
|
174
|
+
localPath,
|
|
173
175
|
});
|
|
174
176
|
sendJson(result.duplicate ? 200 : 202, result);
|
|
175
177
|
} catch (error) {
|
|
@@ -270,7 +272,9 @@ async function handleMutationIngest(body, sendJson) {
|
|
|
270
272
|
const contract = body?.envelope?.contract;
|
|
271
273
|
const result = contract === 'browser-cookie-jar@1'
|
|
272
274
|
? require('../browser/cookie-jar').applyCloudCookieMutation(body)
|
|
273
|
-
:
|
|
275
|
+
: contract === 'workspace-tree@1'
|
|
276
|
+
? require('../workspace/tree-store').applyCloudTreeMutation(body)
|
|
277
|
+
: require('./docs').applyCloudDocMutation(body);
|
|
274
278
|
sendJson(200, result);
|
|
275
279
|
} catch (error) {
|
|
276
280
|
sendHandlerError(error, sendJson);
|
|
@@ -296,4 +300,5 @@ module.exports = {
|
|
|
296
300
|
handleReplicaList,
|
|
297
301
|
handleReplicaRegister,
|
|
298
302
|
handleReplicaRehydrate,
|
|
303
|
+
handleWorkspaceContentDiscover,
|
|
299
304
|
};
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// promote as before.
|
|
3
|
+
// Git owns commits, refs, the index, and execution. Workspace collaboration
|
|
4
|
+
// owns included working-tree content, so tracked text files use the same live
|
|
5
|
+
// document rail as every other enrolled text file.
|
|
7
6
|
|
|
8
7
|
const test = require('node:test');
|
|
9
8
|
const assert = require('node:assert/strict');
|
|
@@ -52,7 +51,7 @@ async function promote(filePath) {
|
|
|
52
51
|
return sent;
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
test('a git-tracked file
|
|
54
|
+
test('a git-tracked text file promotes through the document rail', async () => {
|
|
56
55
|
const repo = path.join(tempRoot, 'repo');
|
|
57
56
|
fs.mkdirSync(repo, { recursive: true });
|
|
58
57
|
await run(repo, 'init', '-b', 'main');
|
|
@@ -62,10 +61,7 @@ test('a git-tracked file is refused: git owns it', async () => {
|
|
|
62
61
|
await run(repo, 'commit', '-m', 'initial');
|
|
63
62
|
|
|
64
63
|
const { statusCode, payload } = await promote(tracked);
|
|
65
|
-
assert.equal(statusCode,
|
|
66
|
-
assert.equal(payload.code, 'file_git_owned');
|
|
67
|
-
assert.match(payload.error, /tracked by the repository at /);
|
|
68
|
-
assert.ok(payload.error.includes(fs.realpathSync(repo)), 'the error names the owning repo');
|
|
64
|
+
assert.equal(statusCode, 202, JSON.stringify(payload));
|
|
69
65
|
});
|
|
70
66
|
|
|
71
67
|
test('an untracked file inside a repo has no git owner and promotes', async () => {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-workspace-access-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
process.env.AMALGM_WORKSPACES_DIR = path.join(tempRoot, 'workspaces');
|
|
12
|
+
process.env.AMALGM_COMPUTER_ID = 'access-computer';
|
|
13
|
+
const ownerId = '11111111-1111-4111-8111-111111111111';
|
|
14
|
+
const memberId = '22222222-2222-4222-8222-222222222222';
|
|
15
|
+
fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
|
|
16
|
+
fs.mkdirSync(process.env.AMALGM_WORKSPACES_DIR, { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(process.env.AMALGM_DIR, 'computer.json'), JSON.stringify({
|
|
18
|
+
computer_id: 'access-computer',
|
|
19
|
+
user_id: ownerId,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
23
|
+
const docs = require('../state/docs');
|
|
24
|
+
const { runLegacyMigrations } = require('../state/migrations');
|
|
25
|
+
const { upsertWorkspace } = require('../workspace/store');
|
|
26
|
+
const {
|
|
27
|
+
commitPathCreate,
|
|
28
|
+
enrollWorkspace,
|
|
29
|
+
nodeForPath,
|
|
30
|
+
reconcileWorkspaceTree,
|
|
31
|
+
requirePathCapability,
|
|
32
|
+
} = require('../workspace/tree-store');
|
|
33
|
+
const {
|
|
34
|
+
clearNodePermission,
|
|
35
|
+
resolveAccess,
|
|
36
|
+
revokeWorkspaceUser,
|
|
37
|
+
setNodePermission,
|
|
38
|
+
workspaceEpoch,
|
|
39
|
+
} = require('../workspace/access-store');
|
|
40
|
+
|
|
41
|
+
test.after(() => {
|
|
42
|
+
docs.closeAllDocs();
|
|
43
|
+
closeLocalDb();
|
|
44
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('explicit child grants override inheritance and revoke-all is one epoch change', () => {
|
|
48
|
+
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'project');
|
|
49
|
+
const folder = path.join(root, 'folder');
|
|
50
|
+
const file = path.join(folder, 'note.md');
|
|
51
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
52
|
+
fs.writeFileSync(file, 'hello\n');
|
|
53
|
+
const workspace = upsertWorkspace({ path: root, name: 'Project' });
|
|
54
|
+
enrollWorkspace(workspace.id);
|
|
55
|
+
const folderId = nodeForPath(folder).node.id;
|
|
56
|
+
const fileId = nodeForPath(file).node.id;
|
|
57
|
+
|
|
58
|
+
setNodePermission({ actorUserId: ownerId, userId: memberId, nodeId: workspace.id, role: 'viewer' });
|
|
59
|
+
assert.equal(resolveAccess(fileId, memberId).role, 'viewer');
|
|
60
|
+
setNodePermission({ actorUserId: ownerId, userId: memberId, nodeId: folderId, role: 'editor' });
|
|
61
|
+
assert.equal(resolveAccess(fileId, memberId).role, 'editor');
|
|
62
|
+
setNodePermission({ actorUserId: ownerId, userId: memberId, nodeId: fileId, role: 'no_access' });
|
|
63
|
+
assert.equal(resolveAccess(fileId, memberId).role, 'no_access');
|
|
64
|
+
clearNodePermission({ actorUserId: ownerId, userId: memberId, nodeId: fileId });
|
|
65
|
+
assert.equal(resolveAccess(fileId, memberId).role, 'editor');
|
|
66
|
+
|
|
67
|
+
const before = workspaceEpoch(workspace.id);
|
|
68
|
+
const revoked = revokeWorkspaceUser({ actorUserId: ownerId, userId: memberId, workspaceId: workspace.id });
|
|
69
|
+
assert.equal(revoked.revoked, 2);
|
|
70
|
+
assert.equal(revoked.authorityEpoch, before + 1);
|
|
71
|
+
assert.equal(resolveAccess(fileId, memberId).role, null);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('migration backfills the signed-in owner only for locally owned workspaces', () => {
|
|
75
|
+
const localRoot = path.join(process.env.AMALGM_WORKSPACES_DIR, 'backfill-local');
|
|
76
|
+
const cloudRoot = path.join(process.env.AMALGM_WORKSPACES_DIR, 'backfill-cloud');
|
|
77
|
+
fs.mkdirSync(localRoot);
|
|
78
|
+
fs.mkdirSync(cloudRoot);
|
|
79
|
+
const local = upsertWorkspace({ path: localRoot, name: 'Local', source: 'computer' });
|
|
80
|
+
const cloud = upsertWorkspace(
|
|
81
|
+
{ path: cloudRoot, name: 'Cloud', source: 'cloud' },
|
|
82
|
+
{ seedOwner: false },
|
|
83
|
+
);
|
|
84
|
+
const database = openLocalDb();
|
|
85
|
+
database.prepare('DELETE FROM workspace_node_permissions WHERE workspace_id = ?').run(local.id);
|
|
86
|
+
|
|
87
|
+
runLegacyMigrations(database);
|
|
88
|
+
|
|
89
|
+
assert.equal(resolveAccess(local.id, ownerId, { database }).role, 'owner');
|
|
90
|
+
assert.equal(resolveAccess(cloud.id, ownerId, { database }).role, null);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('viewer disk changes are held instead of entering the mutation journal', () => {
|
|
94
|
+
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'viewer-projection');
|
|
95
|
+
fs.mkdirSync(root);
|
|
96
|
+
const seededPath = path.join(root, 'seeded.txt');
|
|
97
|
+
fs.writeFileSync(seededPath, 'original\n');
|
|
98
|
+
const workspace = upsertWorkspace(
|
|
99
|
+
{ path: root, name: 'Viewer projection', source: 'cloud' },
|
|
100
|
+
{ seedOwner: false },
|
|
101
|
+
);
|
|
102
|
+
enrollWorkspace(workspace.id);
|
|
103
|
+
const database = openLocalDb();
|
|
104
|
+
database.prepare(`
|
|
105
|
+
INSERT INTO workspace_node_permissions(workspace_id, node_id, user_id, role)
|
|
106
|
+
VALUES (?, ?, ?, 'viewer')
|
|
107
|
+
`).run(workspace.id, workspace.id, ownerId);
|
|
108
|
+
|
|
109
|
+
const observedPath = path.join(root, 'from-shell.txt');
|
|
110
|
+
fs.writeFileSync(observedPath, 'local-only\n');
|
|
111
|
+
const reconciled = reconcileWorkspaceTree(workspace.id, { database });
|
|
112
|
+
assert.equal(reconciled.operations.length, 0);
|
|
113
|
+
assert.equal(reconciled.held.length, 1);
|
|
114
|
+
assert.equal(reconciled.held[0].operation.kind, 'tree.create');
|
|
115
|
+
assert.equal(nodeForPath(observedPath, { database }), null);
|
|
116
|
+
assert.throws(() => commitPathCreate(path.join(root, 'from-api.txt'), 'file', { database }), /forbidden/);
|
|
117
|
+
assert.throws(() => requirePathCapability(root, 'edit', { database }), /forbidden/);
|
|
118
|
+
assert.throws(() => docs.writeDocText(seededPath, 'viewer edit\n'), /forbidden/);
|
|
119
|
+
assert.equal(docs.getDocText(seededPath), 'original\n');
|
|
120
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFile } = require('child_process');
|
|
9
|
+
const { promisify } = require('util');
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-checkpoint-test-'));
|
|
14
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
15
|
+
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
16
|
+
process.env.AMALGM_COMPUTER_ID = 'machine-a';
|
|
17
|
+
process.env.AMALGM_DEVICE_ID = 'device-machine-a';
|
|
18
|
+
|
|
19
|
+
fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
|
|
20
|
+
fs.writeFileSync(
|
|
21
|
+
path.join(process.env.AMALGM_DIR, 'computer.json'),
|
|
22
|
+
JSON.stringify({ computer_id: 'machine-a', user_id: '11111111-1111-4111-8111-111111111111' }),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const { closeLocalDb } = require('../state/db');
|
|
26
|
+
const { captureWorktreeDiff } = require('../workspace/git');
|
|
27
|
+
const { applyPatch } = require('../workspace/transition');
|
|
28
|
+
const { checkpointResourceId, revisionOf } = require('../workspace/checkpoint');
|
|
29
|
+
|
|
30
|
+
test.after(() => {
|
|
31
|
+
closeLocalDb();
|
|
32
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function run(cwd, ...args) {
|
|
36
|
+
await execFileAsync(
|
|
37
|
+
'git',
|
|
38
|
+
['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
|
|
39
|
+
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function makeRepoPair() {
|
|
44
|
+
const stamp = Math.random().toString(36).slice(2, 8);
|
|
45
|
+
const author = path.join(tempRoot, `author-${stamp}`);
|
|
46
|
+
fs.mkdirSync(author, { recursive: true });
|
|
47
|
+
await run(author, 'init', '-b', 'main');
|
|
48
|
+
fs.writeFileSync(path.join(author, 'README.md'), '# app\nline two\n');
|
|
49
|
+
fs.mkdirSync(path.join(author, 'src'));
|
|
50
|
+
fs.writeFileSync(path.join(author, 'src', 'index.js'), 'console.log(1);\n');
|
|
51
|
+
fs.writeFileSync(path.join(author, '.gitignore'), 'node_modules/\n');
|
|
52
|
+
fs.writeFileSync(path.join(author, 'LICENSE'), 'MIT\n');
|
|
53
|
+
await run(author, 'add', '.');
|
|
54
|
+
await run(author, 'commit', '-m', 'initial');
|
|
55
|
+
const receiver = path.join(tempRoot, `receiver-${stamp}`);
|
|
56
|
+
await run(tempRoot, 'clone', '-q', author, receiver);
|
|
57
|
+
return { author, receiver };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test('a dirty worktree round-trips through capture and apply, gitignore filtering included', async () => {
|
|
61
|
+
const { author, receiver } = await makeRepoPair();
|
|
62
|
+
|
|
63
|
+
// Every dirty shape at once: tracked edit, staged edit, deletion, new
|
|
64
|
+
// text file in a new folder, binary file, and ignored junk.
|
|
65
|
+
fs.appendFileSync(path.join(author, 'README.md'), 'edited\n');
|
|
66
|
+
fs.writeFileSync(path.join(author, 'src', 'index.js'), 'console.log(2);\n');
|
|
67
|
+
await run(author, 'add', 'src/index.js');
|
|
68
|
+
fs.rmSync(path.join(author, 'LICENSE'));
|
|
69
|
+
fs.mkdirSync(path.join(author, 'lib'));
|
|
70
|
+
fs.writeFileSync(path.join(author, 'lib', 'util.js'), 'module.exports = 42;\n');
|
|
71
|
+
fs.writeFileSync(path.join(author, 'blob.bin'), Buffer.from([0, 1, 2, 255, 0, 7]));
|
|
72
|
+
fs.mkdirSync(path.join(author, 'node_modules', 'junk'), { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(author, 'node_modules', 'junk', 'x.js'), 'never travels\n');
|
|
74
|
+
|
|
75
|
+
const captured = await captureWorktreeDiff(author);
|
|
76
|
+
assert.ok(captured.diff.length > 0);
|
|
77
|
+
assert.ok(!captured.diff.includes('node_modules'), 'ignored files must never enter a checkpoint');
|
|
78
|
+
|
|
79
|
+
const patchFile = path.join(tempRoot, 'roundtrip.patch');
|
|
80
|
+
fs.writeFileSync(patchFile, captured.diff);
|
|
81
|
+
await applyPatch(receiver, patchFile);
|
|
82
|
+
|
|
83
|
+
assert.equal(
|
|
84
|
+
fs.readFileSync(path.join(receiver, 'README.md'), 'utf8'),
|
|
85
|
+
fs.readFileSync(path.join(author, 'README.md'), 'utf8'),
|
|
86
|
+
);
|
|
87
|
+
assert.equal(
|
|
88
|
+
fs.readFileSync(path.join(receiver, 'src', 'index.js'), 'utf8'),
|
|
89
|
+
'console.log(2);\n',
|
|
90
|
+
);
|
|
91
|
+
assert.equal(
|
|
92
|
+
fs.readFileSync(path.join(receiver, 'lib', 'util.js'), 'utf8'),
|
|
93
|
+
'module.exports = 42;\n',
|
|
94
|
+
);
|
|
95
|
+
assert.deepEqual(
|
|
96
|
+
fs.readFileSync(path.join(receiver, 'blob.bin')),
|
|
97
|
+
Buffer.from([0, 1, 2, 255, 0, 7]),
|
|
98
|
+
);
|
|
99
|
+
assert.equal(fs.existsSync(path.join(receiver, 'LICENSE')), false, 'deletions travel');
|
|
100
|
+
assert.equal(fs.existsSync(path.join(receiver, 'node_modules')), false);
|
|
101
|
+
|
|
102
|
+
// And the reverse of the same patch restores the receiver to clean.
|
|
103
|
+
await applyPatch(receiver, patchFile, { reverse: true });
|
|
104
|
+
const after = await captureWorktreeDiff(receiver);
|
|
105
|
+
assert.equal(after.diff, '');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('capture is stable: two reads of an untouched tree produce the same revision', async () => {
|
|
109
|
+
const { author } = await makeRepoPair();
|
|
110
|
+
fs.appendFileSync(path.join(author, 'README.md'), 'same edit\n');
|
|
111
|
+
const first = await captureWorktreeDiff(author);
|
|
112
|
+
const second = await captureWorktreeDiff(author);
|
|
113
|
+
assert.equal(first.diff, second.diff);
|
|
114
|
+
assert.equal(revisionOf(first.baseOid, first.diff), revisionOf(second.baseOid, second.diff));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a clean tree captures as an empty patch, and oversized state is a named refusal', async () => {
|
|
118
|
+
const { author } = await makeRepoPair();
|
|
119
|
+
const clean = await captureWorktreeDiff(author);
|
|
120
|
+
assert.equal(clean.diff, '');
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(path.join(author, 'big.txt'), 'x'.repeat(4096));
|
|
123
|
+
const bounded = await captureWorktreeDiff(author, { maxBytes: 100 });
|
|
124
|
+
assert.equal(bounded.diff, null);
|
|
125
|
+
assert.equal(bounded.reason, 'too-large');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('checkpoint resource ids are stable per (user, machine, repo) and distinct across each', () => {
|
|
129
|
+
const user = '11111111-1111-4111-8111-111111111111';
|
|
130
|
+
const id = checkpointResourceId('m1', 'https://github.com/a/b', user);
|
|
131
|
+
assert.equal(id, checkpointResourceId('m1', 'https://github.com/a/b', user));
|
|
132
|
+
assert.match(id, /^shr_[A-Za-z0-9_-]{16,128}$/);
|
|
133
|
+
assert.notEqual(id, checkpointResourceId('m2', 'https://github.com/a/b', user));
|
|
134
|
+
assert.notEqual(id, checkpointResourceId('m1', 'https://github.com/a/c', user));
|
|
135
|
+
assert.notEqual(id, checkpointResourceId('m1', 'https://github.com/a/b', 'other-user'));
|
|
136
|
+
assert.equal(checkpointResourceId(null, 'x', user), null);
|
|
137
|
+
});
|