amalgm 0.1.246 → 0.1.247
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/README.md +5 -3
- package/lib/cli.js +92 -4
- package/lib/shared-realtime-tunnel.js +82 -19
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/automations/deliveries.js +276 -0
- package/runtime/scripts/amalgm-mcp/automations/rest.js +51 -0
- package/runtime/scripts/amalgm-mcp/automations/scheduler.js +11 -67
- package/runtime/scripts/amalgm-mcp/automations/store.js +67 -20
- package/runtime/scripts/amalgm-mcp/events/ingress.js +69 -215
- package/runtime/scripts/amalgm-mcp/events/processor.js +189 -0
- package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +41 -0
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +107 -1
- package/runtime/scripts/amalgm-mcp/lib/generated/email-render.json +10 -2
- package/runtime/scripts/amalgm-mcp/observer/README.md +8 -6
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +262 -22
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +86 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +4 -1
- package/runtime/scripts/amalgm-mcp/registration/service.js +102 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +19 -5
- package/runtime/scripts/amalgm-mcp/registry/index.js +16 -1
- package/runtime/scripts/amalgm-mcp/registry/store.js +58 -7
- package/runtime/scripts/amalgm-mcp/server/routes/automations.js +8 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +56 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +14 -0
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +1 -1
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +2 -2
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/citations.html +5 -5
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/links.html +3 -3
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/notification-shell.html +1 -1
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/share-invite.html +13 -0
- package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/text.html +1 -1
- package/runtime/scripts/amalgm-mcp/tests/email-render.test.js +17 -1
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +189 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-content.test.js +126 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +126 -11
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +20 -6
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +8 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +8 -1
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +630 -102
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +45 -10
- package/runtime/scripts/amalgm-mcp/tests/workspace-materialize.test.js +54 -0
- package/runtime/scripts/amalgm-mcp/workspace/materialize.js +292 -0
- package/runtime/scripts/chat-core/contract.js +21 -3
- package/runtime/scripts/chat-core/engine.js +8 -0
- package/runtime/scripts/chat-core/server.js +11 -1
- package/runtime/scripts/chat-core/tests/workspace-preflight.test.js +116 -0
- package/runtime/scripts/chat-core/workspace-preflight.js +89 -0
- package/runtime/scripts/local-gateway.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The shared registration boundary (`amalgm
|
|
4
|
+
* The shared registration boundary (`amalgm register`) proving the composed
|
|
5
5
|
* story the audits demanded: every enrolled root exposes symlink edges
|
|
6
6
|
* regardless of its mutation adapter; watched ground is ALWAYS
|
|
7
7
|
* registered ground (a followed target gets a tree, a root record, a
|
|
@@ -29,8 +29,14 @@ const { shouldEnroll: defaultPolicy } = require('../workspace/tree/inclusion');
|
|
|
29
29
|
const { installFakeWatch } = require('./fake-watch');
|
|
30
30
|
|
|
31
31
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-registration-'));
|
|
32
|
+
const rigs = [];
|
|
32
33
|
|
|
33
34
|
test.after(() => {
|
|
35
|
+
for (const rig of rigs) {
|
|
36
|
+
rig.registration.stop();
|
|
37
|
+
rig.observer.stop();
|
|
38
|
+
rig.identityDb.close();
|
|
39
|
+
}
|
|
34
40
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
35
41
|
});
|
|
36
42
|
|
|
@@ -70,7 +76,7 @@ function makeRig(options = {}) {
|
|
|
70
76
|
emit: (event) => events.push(event),
|
|
71
77
|
});
|
|
72
78
|
ref.current = boot();
|
|
73
|
-
|
|
79
|
+
const rig = {
|
|
74
80
|
observer,
|
|
75
81
|
events,
|
|
76
82
|
identityDb,
|
|
@@ -82,6 +88,8 @@ function makeRig(options = {}) {
|
|
|
82
88
|
return ref.current;
|
|
83
89
|
},
|
|
84
90
|
};
|
|
91
|
+
rigs.push(rig);
|
|
92
|
+
return rig;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
const committedSince = (events, from) => events.slice(from)
|
|
@@ -257,7 +265,7 @@ test('chains resolve like the OS resolves: to the final real ground, in-tree and
|
|
|
257
265
|
'intermediate ground was traversed, never registered: no edge named it as an endpoint');
|
|
258
266
|
});
|
|
259
267
|
|
|
260
|
-
test('repo-contained targets resolve to
|
|
268
|
+
test('repo-contained targets resolve to their registered child entities', () => {
|
|
261
269
|
const rig = makeRig();
|
|
262
270
|
const repo = makeDir('territory');
|
|
263
271
|
gitInit(repo);
|
|
@@ -270,11 +278,11 @@ test('repo-contained targets resolve to the owning entity: the card\'s record an
|
|
|
270
278
|
const { record: wsRoot } = rig.registration.add(ws);
|
|
271
279
|
assert.equal(repoRoot.type, 'repo.git');
|
|
272
280
|
const link = rig.registration.tree(ws).registry.activeAt(wsRoot.uuid, 'docs');
|
|
273
|
-
|
|
274
|
-
|
|
281
|
+
const docs = rig.registration.tree(repo).registry.activeAt(repoRoot.uuid, 'docs');
|
|
282
|
+
assert.equal(rig.registration.linkTargets().get(link.uuid), docs.uuid,
|
|
283
|
+
'a repo boundary changes realtime semantics, not the child identity map');
|
|
275
284
|
|
|
276
|
-
// A nested
|
|
277
|
-
// way down the tree owns everything beneath it.
|
|
285
|
+
// A nested boundary also preserves its registered descendants.
|
|
278
286
|
const rig2 = makeRig();
|
|
279
287
|
const ws2 = makeDir('territory-outer');
|
|
280
288
|
const nested = path.join(ws2, 'vendor');
|
|
@@ -289,8 +297,9 @@ test('repo-contained targets resolve to the owning entity: the card\'s record an
|
|
|
289
297
|
const nestedRecord = rig2.registration.tree(ws2).registry.activeAt(outerRoot.uuid, 'vendor');
|
|
290
298
|
assert.equal(nestedRecord.type, 'repo.git');
|
|
291
299
|
const link2 = rig2.registration.tree(other).registry.activeAt(otherRoot.uuid, 'lib');
|
|
292
|
-
|
|
293
|
-
|
|
300
|
+
const nestedLib = rig2.registration.tree(ws2).registry.activeAt(nestedRecord.uuid, 'lib');
|
|
301
|
+
assert.equal(rig2.registration.linkTargets().get(link2.uuid), nestedLib.uuid,
|
|
302
|
+
'a nested repo uses its Card for realtime while its children remain addressable');
|
|
294
303
|
});
|
|
295
304
|
|
|
296
305
|
// --- the four permanent exams of the second audit --------------------------
|
|
@@ -891,7 +900,7 @@ test('add is one operation: a bare file registers, covered ground returns its tr
|
|
|
891
900
|
fs.writeFileSync(filePath, 't');
|
|
892
901
|
|
|
893
902
|
const solo = rig.registration.add(filePath);
|
|
894
|
-
assert.equal(solo.record.type, 'file.text', '`amalgm
|
|
903
|
+
assert.equal(solo.record.type, 'file.text', '`amalgm register <file>` is the same primitive: a tree of one');
|
|
895
904
|
|
|
896
905
|
const added = rig.registration.add(ws);
|
|
897
906
|
const again = rig.registration.add(path.join(ws, 'a.md'));
|
|
@@ -902,6 +911,32 @@ test('add is one operation: a bare file registers, covered ground returns its tr
|
|
|
902
911
|
'ground that would swallow a registered root refuses loudly');
|
|
903
912
|
});
|
|
904
913
|
|
|
914
|
+
test('add never claims success when a required watcher cannot open', () => {
|
|
915
|
+
const dir = makeDir('degraded-add');
|
|
916
|
+
const observer = createObserver({
|
|
917
|
+
db: new Database(':memory:'),
|
|
918
|
+
emit: () => {},
|
|
919
|
+
shouldEnroll: () => true,
|
|
920
|
+
watch: () => ({ degraded: true, close: () => {} }),
|
|
921
|
+
});
|
|
922
|
+
const identityDb = new Database(':memory:');
|
|
923
|
+
const registration = createRegistration({
|
|
924
|
+
observer,
|
|
925
|
+
openIdentity: () => ({ db: identityDb, ephemeral: true }),
|
|
926
|
+
classify: () => 'file.text',
|
|
927
|
+
});
|
|
928
|
+
try {
|
|
929
|
+
assert.throws(() => registration.add(dir), /could not start required watcher/,
|
|
930
|
+
'a registered tree with no live doorbell is an incomplete add, never a success');
|
|
931
|
+
assert.ok(registration.tree(dir)?.root(),
|
|
932
|
+
'identity remains durable for a later explicit retry; add only refuses to lie about Watch');
|
|
933
|
+
} finally {
|
|
934
|
+
registration.stop();
|
|
935
|
+
observer.stop();
|
|
936
|
+
identityDb.close();
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
|
|
905
940
|
test('the would-swallow edge refusal is visible on the boundary, never silent', () => {
|
|
906
941
|
const rig = makeRig();
|
|
907
942
|
const holder = makeDir('report-holder');
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
const { _private } = require('../workspace/materialize');
|
|
9
|
+
|
|
10
|
+
test('materializer keeps workspace-tree and code-project identities separate', () => {
|
|
11
|
+
assert.deepEqual(_private.normalizeRequest({
|
|
12
|
+
name: 'Shared notes',
|
|
13
|
+
materializer: {
|
|
14
|
+
kind: 'workspace-tree',
|
|
15
|
+
workspaceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
16
|
+
},
|
|
17
|
+
}), {
|
|
18
|
+
kind: 'workspace-tree',
|
|
19
|
+
resourceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
20
|
+
workspaceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
21
|
+
name: 'Shared notes',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.equal(_private.normalizeRequest({
|
|
25
|
+
materializer: {
|
|
26
|
+
kind: 'code-project',
|
|
27
|
+
resourceId: 'shr_1234567890abcdef',
|
|
28
|
+
},
|
|
29
|
+
}).kind, 'code-project');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('code-project identity is read from the attached wall, not trusted from a client path', () => {
|
|
33
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-materialize-'));
|
|
34
|
+
const wallFile = path.join(dir, 'wall.jsonl');
|
|
35
|
+
fs.writeFileSync(wallFile, [
|
|
36
|
+
'{"wall":"code-project@3","project":"repo-key","machine":"peer","cards":[]}',
|
|
37
|
+
'{"partial":',
|
|
38
|
+
].join('\n'));
|
|
39
|
+
assert.equal(_private.repoKeyFromWallFile(wallFile), 'repo-key');
|
|
40
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('materializer rejects client-supplied kinds and malformed resource ids', () => {
|
|
44
|
+
assert.throws(
|
|
45
|
+
() => _private.normalizeRequest({ materializer: { kind: 'filesystem', path: '/tmp' } }),
|
|
46
|
+
/unsupported workspace materializer/,
|
|
47
|
+
);
|
|
48
|
+
assert.throws(
|
|
49
|
+
() => _private.normalizeRequest({
|
|
50
|
+
materializer: { kind: 'code-project', resourceId: 'invite-secret' },
|
|
51
|
+
}),
|
|
52
|
+
/opaque shared id/,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one local materialization boundary used before an agent contract freezes
|
|
5
|
+
* its cwd. Workspace trees and code projects deliberately keep their existing,
|
|
6
|
+
* separate replication machinery; this module only waits for either one to
|
|
7
|
+
* produce a usable folder.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
14
|
+
const SHARED_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
16
|
+
const POLL_MS = 250;
|
|
17
|
+
|
|
18
|
+
function invalid(message, statusCode = 400, code = 'workspace_materialization_invalid') {
|
|
19
|
+
return Object.assign(new Error(message), { statusCode, code });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sanitizeName(value, fallback) {
|
|
23
|
+
return String(value || fallback)
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/[\/\\:\0]/g, '-')
|
|
26
|
+
.replace(/^\.+$/, fallback)
|
|
27
|
+
.replace(/^\.+/, '')
|
|
28
|
+
.slice(0, 120) || fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeRequest(input) {
|
|
32
|
+
const materializer = input?.materializer;
|
|
33
|
+
if (!materializer || typeof materializer !== 'object') {
|
|
34
|
+
throw invalid('materializer is required');
|
|
35
|
+
}
|
|
36
|
+
if (materializer.kind === 'workspace-tree') {
|
|
37
|
+
const workspaceId = String(materializer.workspaceId || input?.workspaceId || '').toLowerCase();
|
|
38
|
+
if (!UUID.test(workspaceId)) throw invalid('workspaceId must be a UUID');
|
|
39
|
+
return {
|
|
40
|
+
kind: 'workspace-tree',
|
|
41
|
+
resourceId: workspaceId,
|
|
42
|
+
workspaceId,
|
|
43
|
+
name: sanitizeName(input?.name, 'Shared workspace'),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (materializer.kind === 'code-project') {
|
|
47
|
+
const resourceId = String(materializer.resourceId || input?.resourceId || '');
|
|
48
|
+
if (!SHARED_ID.test(resourceId)) {
|
|
49
|
+
throw invalid('code project resourceId must be an opaque shared id');
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
kind: 'code-project',
|
|
53
|
+
resourceId,
|
|
54
|
+
name: sanitizeName(input?.name, 'Shared code project'),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
throw invalid('unsupported workspace materializer');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function delay(ms) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const timer = setTimeout(resolve, ms);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function callRest(handler, body) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
Promise.resolve(handler(body, (status, payload) => {
|
|
70
|
+
if (status >= 400) {
|
|
71
|
+
reject(invalid(
|
|
72
|
+
payload?.error || `workspace materialization failed with ${status}`,
|
|
73
|
+
status,
|
|
74
|
+
payload?.code || 'workspace_materialization_failed',
|
|
75
|
+
));
|
|
76
|
+
} else {
|
|
77
|
+
resolve(payload);
|
|
78
|
+
}
|
|
79
|
+
})).catch(reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isInside(root, candidate) {
|
|
84
|
+
const relative = path.relative(root, candidate);
|
|
85
|
+
return relative === '' || (
|
|
86
|
+
relative
|
|
87
|
+
&& !relative.startsWith('..')
|
|
88
|
+
&& !path.isAbsolute(relative)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function incompleteContentAttachments(rootPath) {
|
|
93
|
+
const rows = require('../state/db').openLocalDb()
|
|
94
|
+
.prepare(`
|
|
95
|
+
SELECT resource_id, local_path, attachment_state, last_error
|
|
96
|
+
FROM shared_resource_attachments
|
|
97
|
+
`)
|
|
98
|
+
.all();
|
|
99
|
+
return rows.filter((row) => (
|
|
100
|
+
row.local_path !== rootPath
|
|
101
|
+
&& isInside(rootPath, row.local_path)
|
|
102
|
+
&& row.attachment_state !== 'attached'
|
|
103
|
+
));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function waitForTree(resourceId, timeoutMs) {
|
|
107
|
+
const replicas = require('../state/replicas');
|
|
108
|
+
const attachments = require('../state/attachments');
|
|
109
|
+
const deadline = Date.now() + timeoutMs;
|
|
110
|
+
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
const replica = replicas.getReplicaByResourceId(resourceId);
|
|
113
|
+
const rootAttachment = attachments.getAttachmentByResource(resourceId);
|
|
114
|
+
if (rootAttachment?.state === 'rejected') {
|
|
115
|
+
throw invalid(
|
|
116
|
+
rootAttachment.lastError || 'Cloud workspace attachment was rejected.',
|
|
117
|
+
409,
|
|
118
|
+
'workspace_materialization_rejected',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (replica?.accessStatus === 'revoked') {
|
|
122
|
+
throw invalid(
|
|
123
|
+
'Access to this cloud workspace was removed.',
|
|
124
|
+
403,
|
|
125
|
+
'workspace_materialization_revoked',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (replica?.localPath && fs.existsSync(replica.localPath)) {
|
|
129
|
+
const content = incompleteContentAttachments(replica.localPath);
|
|
130
|
+
const rejected = content.find((row) => row.attachment_state === 'rejected');
|
|
131
|
+
if (rejected) {
|
|
132
|
+
throw invalid(
|
|
133
|
+
rejected.last_error || 'A cloud workspace file could not be downloaded.',
|
|
134
|
+
409,
|
|
135
|
+
'workspace_content_materialization_rejected',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (content.length === 0) return replica;
|
|
139
|
+
}
|
|
140
|
+
await delay(POLL_MS);
|
|
141
|
+
}
|
|
142
|
+
throw invalid(
|
|
143
|
+
'Cloud workspace is still downloading. Try again in a moment.',
|
|
144
|
+
504,
|
|
145
|
+
'workspace_materialization_timeout',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function chooseCodeProjectPath(name) {
|
|
150
|
+
const root = require('./rest').getWorkspaceRoot();
|
|
151
|
+
await fs.promises.mkdir(root, { recursive: true });
|
|
152
|
+
const base = sanitizeName(name, 'Shared code project');
|
|
153
|
+
let candidate = path.join(root, base);
|
|
154
|
+
let suffix = 2;
|
|
155
|
+
while (fs.existsSync(candidate)) {
|
|
156
|
+
candidate = path.join(root, `${base}-${suffix}`);
|
|
157
|
+
suffix += 1;
|
|
158
|
+
}
|
|
159
|
+
return candidate;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function ensureCodeWallAttached(resourceId, timeoutMs) {
|
|
163
|
+
const wall = require('./wall');
|
|
164
|
+
const replicas = require('../state/replicas');
|
|
165
|
+
const attachments = require('../state/attachments');
|
|
166
|
+
const filePath = wall.projectFile(resourceId);
|
|
167
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
168
|
+
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, '');
|
|
169
|
+
|
|
170
|
+
let request = attachments.getAttachmentByResource(resourceId);
|
|
171
|
+
if (!replicas.getReplicaByResourceId(resourceId) && !request) {
|
|
172
|
+
await callRest(require('../state/shared-rest').handleAttachmentStart, {
|
|
173
|
+
resourceId,
|
|
174
|
+
path: filePath,
|
|
175
|
+
});
|
|
176
|
+
request = attachments.getAttachmentByResource(resourceId);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const deadline = Date.now() + timeoutMs;
|
|
180
|
+
while (Date.now() < deadline) {
|
|
181
|
+
const replica = replicas.getReplicaByResourceId(resourceId);
|
|
182
|
+
if (replica) return filePath;
|
|
183
|
+
request = attachments.getAttachmentByResource(resourceId);
|
|
184
|
+
if (request?.state === 'rejected') {
|
|
185
|
+
throw invalid(
|
|
186
|
+
request.lastError || 'Code project attachment was rejected.',
|
|
187
|
+
409,
|
|
188
|
+
'code_project_materialization_rejected',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
await delay(POLL_MS);
|
|
192
|
+
}
|
|
193
|
+
throw invalid(
|
|
194
|
+
'Code project is still downloading. Try again in a moment.',
|
|
195
|
+
504,
|
|
196
|
+
'code_project_materialization_timeout',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function repoKeyFromWallFile(filePath) {
|
|
201
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
202
|
+
for (const line of text.split('\n')) {
|
|
203
|
+
if (!line.trim()) continue;
|
|
204
|
+
try {
|
|
205
|
+
const entry = JSON.parse(line);
|
|
206
|
+
if (
|
|
207
|
+
typeof entry?.project === 'string'
|
|
208
|
+
&& entry.project.trim()
|
|
209
|
+
&& String(entry.wall || '').startsWith('code-project@')
|
|
210
|
+
) return entry.project.trim();
|
|
211
|
+
} catch {
|
|
212
|
+
// A trailing partial JSONL line is expected while an update is folding.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function waitForCodeProject(repoKey, targetPath, timeoutMs) {
|
|
219
|
+
const reducer = require('./reducer');
|
|
220
|
+
const deadline = Date.now() + timeoutMs;
|
|
221
|
+
let lastReason = null;
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
await reducer.reduceNow();
|
|
224
|
+
const state = reducer.reducerStats().repos.find((repo) => repo.repoKey === repoKey);
|
|
225
|
+
lastReason = state?.reason || state?.state || lastReason;
|
|
226
|
+
if (
|
|
227
|
+
fs.existsSync(targetPath)
|
|
228
|
+
&& ['hydrated', 'in-sync', 'conflict'].includes(state?.state)
|
|
229
|
+
) return state;
|
|
230
|
+
await delay(POLL_MS);
|
|
231
|
+
}
|
|
232
|
+
throw invalid(
|
|
233
|
+
`Code project could not become ready${lastReason ? `: ${lastReason}` : ''}.`,
|
|
234
|
+
504,
|
|
235
|
+
'code_project_materialization_timeout',
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function materializeWorkspace(input, options = {}) {
|
|
240
|
+
const request = normalizeRequest(input);
|
|
241
|
+
const timeoutMs = Math.max(1_000, Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS);
|
|
242
|
+
const deadline = Date.now() + timeoutMs;
|
|
243
|
+
const remaining = () => Math.max(1_000, deadline - Date.now());
|
|
244
|
+
|
|
245
|
+
if (request.kind === 'workspace-tree') {
|
|
246
|
+
await callRest(require('./rest').handleCloudWorkspaceAttachment, {
|
|
247
|
+
workspaceId: request.workspaceId,
|
|
248
|
+
name: request.name,
|
|
249
|
+
});
|
|
250
|
+
const replica = await waitForTree(request.resourceId, remaining());
|
|
251
|
+
return {
|
|
252
|
+
state: 'ready',
|
|
253
|
+
kind: request.kind,
|
|
254
|
+
resourceId: request.resourceId,
|
|
255
|
+
cwd: replica.localPath,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const wallFile = await ensureCodeWallAttached(request.resourceId, remaining());
|
|
260
|
+
const repoKey = repoKeyFromWallFile(wallFile);
|
|
261
|
+
if (!repoKey) {
|
|
262
|
+
throw invalid(
|
|
263
|
+
'Code project metadata is not available yet.',
|
|
264
|
+
409,
|
|
265
|
+
'code_project_identity_missing',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const wall = require('./wall');
|
|
269
|
+
const existing = wall.projectForRepo(repoKey);
|
|
270
|
+
const targetPath = existing?.path || await chooseCodeProjectPath(request.name);
|
|
271
|
+
wall.startWallService();
|
|
272
|
+
require('./reducer').startWorkspaceReducer();
|
|
273
|
+
wall.bindProject(repoKey, request.resourceId, null, targetPath);
|
|
274
|
+
await waitForCodeProject(repoKey, targetPath, remaining());
|
|
275
|
+
return {
|
|
276
|
+
state: 'ready',
|
|
277
|
+
kind: request.kind,
|
|
278
|
+
resourceId: request.resourceId,
|
|
279
|
+
repoKey,
|
|
280
|
+
cwd: targetPath,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = {
|
|
285
|
+
materializeWorkspace,
|
|
286
|
+
_private: {
|
|
287
|
+
isInside,
|
|
288
|
+
normalizeRequest,
|
|
289
|
+
repoKeyFromWallFile,
|
|
290
|
+
sanitizeName,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
@@ -489,9 +489,24 @@ function resolveCwd(rawCwd, options = {}) {
|
|
|
489
489
|
|| existingDirectory(safeProcessCwd())
|
|
490
490
|
|| os.homedir();
|
|
491
491
|
const expanded = expandHome(rawCwd);
|
|
492
|
-
if (!expanded)
|
|
492
|
+
if (!expanded) {
|
|
493
|
+
if (options.requireCwd) {
|
|
494
|
+
throw Object.assign(
|
|
495
|
+
new Error('Cloud workspace folder is unavailable.'),
|
|
496
|
+
{ code: 'workspace_cwd_unavailable', status: 409 },
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return fallback;
|
|
500
|
+
}
|
|
493
501
|
const absolute = path.isAbsolute(expanded) ? expanded : path.resolve(fallback, expanded);
|
|
494
|
-
|
|
502
|
+
const resolved = existingDirectory(canonicalProjectPath(absolute));
|
|
503
|
+
if (!resolved && options.requireCwd) {
|
|
504
|
+
throw Object.assign(
|
|
505
|
+
new Error('Cloud workspace folder is unavailable.'),
|
|
506
|
+
{ code: 'workspace_cwd_unavailable', status: 409 },
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return resolved || fallback;
|
|
495
510
|
}
|
|
496
511
|
|
|
497
512
|
function nullableString(value) {
|
|
@@ -565,7 +580,10 @@ function createContract(payload, options = {}) {
|
|
|
565
580
|
const reasoningEffort = payload.reasoningEffort || null;
|
|
566
581
|
const fastMode = payload.fastMode === true;
|
|
567
582
|
const cliModel = cliModelFor({ harness, modelId, cliModel: payload.cliModel, reasoningEffort });
|
|
568
|
-
const cwd = resolveCwd(payload.cwd,
|
|
583
|
+
const cwd = resolveCwd(payload.cwd, {
|
|
584
|
+
...options,
|
|
585
|
+
requireCwd: Boolean(payload.cloudWorkspaceRef),
|
|
586
|
+
});
|
|
569
587
|
const localBaseUrl = options.localBaseUrl || `http://127.0.0.1:${options.port || runtimePort('chat-server')}`;
|
|
570
588
|
const origin = normalizeOrigin({ ...payload, cwd });
|
|
571
589
|
const userId = payload.userId || options.userId || 'unknown-user';
|
|
@@ -143,6 +143,14 @@ class ChatCore {
|
|
|
143
143
|
const onTurnStart = typeof options.onTurnStart === 'function' ? options.onTurnStart : null;
|
|
144
144
|
const sessionId = payload.codeSessionId || payload.sessionId;
|
|
145
145
|
if (sessionId) await this.finalizeActiveTurn(sessionId);
|
|
146
|
+
const frozenContract = sessionId ? this.contracts.get(sessionId) : null;
|
|
147
|
+
if (!frozenContract && typeof this.options.prepareWorkspace === 'function') {
|
|
148
|
+
payload = await this.options.prepareWorkspace(payload);
|
|
149
|
+
} else if (frozenContract && payload.cloudWorkspaceRef) {
|
|
150
|
+
// A warm session already owns a frozen cwd. Do not materialize a second
|
|
151
|
+
// workspace only for contractFor() to discard it as a changed field.
|
|
152
|
+
payload = { ...payload, cwd: frozenContract.cwd };
|
|
153
|
+
}
|
|
146
154
|
const contract = this.contractFor(payload);
|
|
147
155
|
await this.beginTurn(contract);
|
|
148
156
|
const input = normalizeInput(payload);
|
|
@@ -16,6 +16,7 @@ const { writeChatStream, writeRawTempStream } = require('./streams');
|
|
|
16
16
|
const { TurnStore } = require('./stores');
|
|
17
17
|
const { resolveMachineChatPayload } = require('./chat-payload');
|
|
18
18
|
const { authorizeRuntimeHttp } = require('../runtime-auth');
|
|
19
|
+
const { materializePayloadWorkspace } = require('./workspace-preflight');
|
|
19
20
|
|
|
20
21
|
function readBody(req) {
|
|
21
22
|
return new Promise((resolve, reject) => {
|
|
@@ -67,7 +68,16 @@ function createCore(options = {}) {
|
|
|
67
68
|
pi: () => new PiAdapter(),
|
|
68
69
|
},
|
|
69
70
|
});
|
|
70
|
-
return new ChatCore({
|
|
71
|
+
return new ChatCore({
|
|
72
|
+
db,
|
|
73
|
+
runtime,
|
|
74
|
+
turns,
|
|
75
|
+
options: {
|
|
76
|
+
...options,
|
|
77
|
+
prepareWorkspace: options.prepareWorkspace || materializePayloadWorkspace,
|
|
78
|
+
port: PORT,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
function createServer(core = createCore()) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
const { createContract } = require('../contract');
|
|
6
|
+
const { ChatCore } = require('../engine');
|
|
7
|
+
const { done } = require('../events');
|
|
8
|
+
const { TurnStore } = require('../stores');
|
|
9
|
+
const { materializePayloadWorkspace } = require('../workspace-preflight');
|
|
10
|
+
|
|
11
|
+
function contractPayload(overrides = {}) {
|
|
12
|
+
return {
|
|
13
|
+
codeSessionId: 'session-cloud',
|
|
14
|
+
assistantMessageId: 'assistant-cloud',
|
|
15
|
+
userId: 'user-cloud',
|
|
16
|
+
agentId: 'codex',
|
|
17
|
+
authMethod: 'amalgm',
|
|
18
|
+
modelId: 'openai/gpt-5.5',
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('workspace preflight replaces cwd before contract creation', async () => {
|
|
24
|
+
const statuses = [];
|
|
25
|
+
const payload = await materializePayloadWorkspace(contractPayload({
|
|
26
|
+
cwd: '/path/that/does/not/exist',
|
|
27
|
+
cloudWorkspaceRef: {
|
|
28
|
+
resourceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
29
|
+
kind: 'workspace',
|
|
30
|
+
materializer: {
|
|
31
|
+
kind: 'workspace-tree',
|
|
32
|
+
workspaceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}), {
|
|
36
|
+
onStatus: (status) => statuses.push(status.phase),
|
|
37
|
+
request: async () => ({ cwd: process.cwd() }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.equal(payload.cwd, process.cwd());
|
|
41
|
+
assert.deepEqual(statuses, ['hydrating', 'ready']);
|
|
42
|
+
assert.equal(createContract(payload).cwd, process.cwd());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('workspace-bound contracts never substitute home for a missing cwd', () => {
|
|
46
|
+
assert.throws(
|
|
47
|
+
() => createContract(contractPayload({
|
|
48
|
+
cwd: '/path/that/does/not/exist',
|
|
49
|
+
cloudWorkspaceRef: {
|
|
50
|
+
resourceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
51
|
+
materializer: {
|
|
52
|
+
kind: 'workspace-tree',
|
|
53
|
+
workspaceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
})),
|
|
57
|
+
(error) => error?.code === 'workspace_cwd_unavailable' && error?.status === 409,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('chat core preflights once, before billing and before the first runtime spawn', async () => {
|
|
62
|
+
const order = [];
|
|
63
|
+
const core = new ChatCore({
|
|
64
|
+
db: {
|
|
65
|
+
saveUserMessage: async () => true,
|
|
66
|
+
ensureAssistantMessage: async () => true,
|
|
67
|
+
saveAssistantMessage: async () => true,
|
|
68
|
+
mergeSessionMetadata: async () => {},
|
|
69
|
+
logUsage: async () => {},
|
|
70
|
+
},
|
|
71
|
+
runtime: {
|
|
72
|
+
get: () => null,
|
|
73
|
+
stop: async () => {},
|
|
74
|
+
destroy: async () => true,
|
|
75
|
+
async *prompt(contract) {
|
|
76
|
+
order.push(`runtime:${contract.cwd}`);
|
|
77
|
+
yield done();
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
turns: new TurnStore(),
|
|
81
|
+
options: {
|
|
82
|
+
prepareWorkspace: async (payload) => {
|
|
83
|
+
order.push('preflight');
|
|
84
|
+
return { ...payload, cwd: process.cwd() };
|
|
85
|
+
},
|
|
86
|
+
beginTurn: async () => {
|
|
87
|
+
order.push('billing');
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const cloudRef = {
|
|
92
|
+
resourceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
93
|
+
materializer: {
|
|
94
|
+
kind: 'workspace-tree',
|
|
95
|
+
workspaceId: '9a637701-8125-4e32-8454-e89973f60632',
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await core.runTurn(contractPayload({
|
|
100
|
+
prompt: 'first',
|
|
101
|
+
cloudWorkspaceRef: cloudRef,
|
|
102
|
+
}));
|
|
103
|
+
await core.runTurn(contractPayload({
|
|
104
|
+
assistantMessageId: 'assistant-cloud-2',
|
|
105
|
+
prompt: 'second',
|
|
106
|
+
cloudWorkspaceRef: cloudRef,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
assert.deepEqual(order, [
|
|
110
|
+
'preflight',
|
|
111
|
+
'billing',
|
|
112
|
+
`runtime:${process.cwd()}`,
|
|
113
|
+
'billing',
|
|
114
|
+
`runtime:${process.cwd()}`,
|
|
115
|
+
]);
|
|
116
|
+
});
|