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.
Files changed (49) hide show
  1. package/README.md +5 -3
  2. package/lib/cli.js +92 -4
  3. package/lib/shared-realtime-tunnel.js +82 -19
  4. package/package.json +1 -1
  5. package/runtime/scripts/amalgm-mcp/automations/deliveries.js +276 -0
  6. package/runtime/scripts/amalgm-mcp/automations/rest.js +51 -0
  7. package/runtime/scripts/amalgm-mcp/automations/scheduler.js +11 -67
  8. package/runtime/scripts/amalgm-mcp/automations/store.js +67 -20
  9. package/runtime/scripts/amalgm-mcp/events/ingress.js +69 -215
  10. package/runtime/scripts/amalgm-mcp/events/processor.js +189 -0
  11. package/runtime/scripts/amalgm-mcp/lib/email-embeds.js +41 -0
  12. package/runtime/scripts/amalgm-mcp/lib/email-md.js +107 -1
  13. package/runtime/scripts/amalgm-mcp/lib/generated/email-render.json +10 -2
  14. package/runtime/scripts/amalgm-mcp/observer/README.md +8 -6
  15. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +262 -22
  16. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +86 -0
  17. package/runtime/scripts/amalgm-mcp/registration/index.js +4 -1
  18. package/runtime/scripts/amalgm-mcp/registration/service.js +102 -0
  19. package/runtime/scripts/amalgm-mcp/registration/tree.js +19 -5
  20. package/runtime/scripts/amalgm-mcp/registry/index.js +16 -1
  21. package/runtime/scripts/amalgm-mcp/registry/store.js +58 -7
  22. package/runtime/scripts/amalgm-mcp/server/routes/automations.js +8 -0
  23. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +56 -0
  24. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +14 -0
  25. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +1 -1
  26. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +2 -2
  27. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/citations.html +5 -5
  28. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/links.html +3 -3
  29. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/notification-shell.html +1 -1
  30. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/share-invite.html +13 -0
  31. package/runtime/scripts/amalgm-mcp/tests/email-render-goldens/text.html +1 -1
  32. package/runtime/scripts/amalgm-mcp/tests/email-render.test.js +17 -1
  33. package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +189 -0
  34. package/runtime/scripts/amalgm-mcp/tests/entity-content.test.js +126 -0
  35. package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +118 -0
  36. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +126 -11
  37. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +20 -6
  38. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +8 -0
  39. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +8 -1
  40. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +630 -102
  41. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +45 -10
  42. package/runtime/scripts/amalgm-mcp/tests/workspace-materialize.test.js +54 -0
  43. package/runtime/scripts/amalgm-mcp/workspace/materialize.js +292 -0
  44. package/runtime/scripts/chat-core/contract.js +21 -3
  45. package/runtime/scripts/chat-core/engine.js +8 -0
  46. package/runtime/scripts/chat-core/server.js +11 -1
  47. package/runtime/scripts/chat-core/tests/workspace-preflight.test.js +116 -0
  48. package/runtime/scripts/chat-core/workspace-preflight.js +89 -0
  49. 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 add`) proving the composed
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
- return {
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 the owning entity: the card\'s record answers for its insides', () => {
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
- assert.equal(rig.registration.linkTargets().get(link.uuid), repoRoot.uuid,
274
- 'the repo is the owning sync entity, so its UUID answers for every address inside it — never a null that pretends no owner exists');
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 territory answers the same way: the repo.git record on the
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
- assert.equal(rig2.registration.linkTargets().get(link2.uuid), nestedRecord.uuid,
293
- 'a nested boundary\'s record owns its subtree');
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 add <file>` is the same primitive: a tree of one');
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) return fallback;
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
- return existingDirectory(canonicalProjectPath(absolute)) || fallback;
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, options);
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({ db, runtime, turns, options: { ...options, port: PORT } });
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
+ });