amalgm 0.1.36 → 0.1.37

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.
@@ -8,6 +8,13 @@ const path = require('path');
8
8
  const { execFile } = require('child_process');
9
9
  const { promisify } = require('util');
10
10
  const { AMALGM_DIR, DEFAULT_CWD } = require('../config');
11
+ const activeMemory = require('../../chat-core/tooling/active-memory');
12
+ const {
13
+ listWorkspaces,
14
+ removeWorkspace,
15
+ renameWorkspace,
16
+ upsertWorkspace,
17
+ } = require('./store');
11
18
 
12
19
  const execFileAsync = promisify(execFile);
13
20
  const MAX_BUFFER = 10 * 1024 * 1024;
@@ -41,6 +48,16 @@ async function ensureWorkspaceRoot() {
41
48
  return workspaceRoot;
42
49
  }
43
50
 
51
+ function ensureProjectMemory(workspace, source = 'workspace') {
52
+ if (!workspace) return;
53
+ activeMemory.ensureConstructMemory({
54
+ type: 'project',
55
+ id: workspace.id,
56
+ name: workspace.name,
57
+ projectPath: workspace.path,
58
+ }, { source });
59
+ }
60
+
44
61
  async function uniqueProjectPath(workspaceRoot, requestedName) {
45
62
  const name = sanitizeProjectName(requestedName);
46
63
  let candidate = path.join(workspaceRoot, name);
@@ -60,6 +77,11 @@ function isValidWorkspacePath(workspacePath) {
60
77
  return typeof workspacePath === 'string' && workspacePath.length > 0 && path.isAbsolute(workspacePath);
61
78
  }
62
79
 
80
+ function shouldRefreshBranches(query) {
81
+ const value = query?.refresh;
82
+ return value === true || value === 'true' || value === '1' || value === 'yes';
83
+ }
84
+
63
85
  async function runGit(args, cwd) {
64
86
  try {
65
87
  const { stdout, stderr } = await execFileAsync('git', args, {
@@ -137,28 +159,100 @@ function getLocalWorktreePath(workspacePath, shortId) {
137
159
 
138
160
  async function handleProjects(sendJson) {
139
161
  try {
140
- const rootDir = await ensureWorkspaceRoot();
141
- const dirents = await fs.promises.readdir(rootDir, { withFileTypes: true }).catch(() => []);
142
- const projects = [];
162
+ const rootDir = getWorkspaceRoot();
163
+ const projects = await Promise.all(
164
+ listWorkspaces().map(async (workspace) => ({
165
+ ...workspace,
166
+ branch: workspace.path ? await readBranch(workspace.path) : null,
167
+ })),
168
+ );
143
169
 
144
- for (const dirent of dirents) {
145
- if (!dirent.isDirectory() || dirent.name.startsWith('.')) continue;
170
+ projects.sort((a, b) => a.name.localeCompare(b.name));
171
+ sendJson(200, { projects, workspaceRoot: rootDir });
172
+ } catch (error) {
173
+ sendJson(500, {
174
+ error: error instanceof Error ? error.message : 'Failed to list workspaces',
175
+ });
176
+ }
177
+ }
146
178
 
147
- const projectPath = path.join(rootDir, dirent.name);
148
- const branch = await readBranch(projectPath);
179
+ async function handleProjectRegister(body, sendJson) {
180
+ const workspacePath = body?.path || body?.source_path;
181
+ const name = body?.name || body?.project_name;
149
182
 
150
- projects.push({
151
- name: dirent.name,
152
- path: projectPath,
153
- branch,
154
- });
183
+ if (!workspacePath || typeof workspacePath !== 'string') {
184
+ sendJson(400, { error: 'path is required' });
185
+ return;
186
+ }
187
+
188
+ if (!path.isAbsolute(workspacePath)) {
189
+ sendJson(400, { error: 'path must be absolute' });
190
+ return;
191
+ }
192
+
193
+ try {
194
+ const sourceStats = await fs.promises.stat(workspacePath);
195
+ if (!sourceStats.isDirectory()) {
196
+ sendJson(400, { error: 'path must be a folder' });
197
+ return;
155
198
  }
156
199
 
157
- projects.sort((a, b) => a.name.localeCompare(b.name));
158
- sendJson(200, { projects, workspaceRoot: rootDir });
200
+ const workspaceRoot = await ensureWorkspaceRoot();
201
+ const workspace = upsertWorkspace({
202
+ path: await fs.promises.realpath(workspacePath),
203
+ name,
204
+ source: body?.source || 'computer',
205
+ }, {
206
+ workspaceRoot,
207
+ source: 'workspace:register',
208
+ });
209
+ ensureProjectMemory(workspace, 'workspace:register');
210
+
211
+ sendJson(200, {
212
+ success: true,
213
+ workspace,
214
+ path: workspace.path,
215
+ name: workspace.name,
216
+ uri: workspace.uri,
217
+ workspaceRoot,
218
+ });
159
219
  } catch (error) {
160
220
  sendJson(500, {
161
- error: error instanceof Error ? error.message : 'Failed to list workspaces',
221
+ error: error instanceof Error ? error.message : 'Failed to register workspace',
222
+ });
223
+ }
224
+ }
225
+
226
+ async function handleProjectUpdate(body, sendJson) {
227
+ const identifier = body?.id || body?.path;
228
+ if (!identifier || typeof identifier !== 'string') {
229
+ sendJson(400, { error: 'id or path is required' });
230
+ return;
231
+ }
232
+
233
+ try {
234
+ const workspace = renameWorkspace(identifier, body?.name, { source: 'workspace:update' });
235
+ ensureProjectMemory(workspace, 'workspace:update');
236
+ sendJson(200, { success: true, workspace, path: workspace.path, name: workspace.name });
237
+ } catch (error) {
238
+ const message = error instanceof Error ? error.message : 'Failed to update workspace';
239
+ sendJson(message === 'Workspace not found' ? 404 : 500, { error: message });
240
+ }
241
+ }
242
+
243
+ async function handleProjectDelete(body, sendJson) {
244
+ const identifier = body?.id || body?.path;
245
+ if (!identifier || typeof identifier !== 'string') {
246
+ sendJson(400, { error: 'id or path is required' });
247
+ return;
248
+ }
249
+
250
+ try {
251
+ const workspace = removeWorkspace(identifier, { source: 'workspace:delete' });
252
+ sendJson(200, { success: true, workspace });
253
+ } catch (error) {
254
+ sendJson(500, {
255
+ error: error instanceof Error ? error.message : 'Failed to remove workspace',
162
256
  });
163
257
  }
164
258
  }
@@ -191,10 +285,21 @@ async function handleImport(body, sendJson) {
191
285
  ]);
192
286
 
193
287
  if (isWithin(realWorkspaceRoot, realSource)) {
288
+ const workspace = upsertWorkspace({
289
+ path: realSource,
290
+ name: projectName || path.basename(realSource),
291
+ source: 'managed',
292
+ managed: true,
293
+ }, {
294
+ workspaceRoot,
295
+ source: 'workspace:import',
296
+ });
297
+ ensureProjectMemory(workspace, 'workspace:import');
194
298
  sendJson(200, {
195
299
  success: true,
196
- path: realSource,
197
- name: path.basename(realSource),
300
+ path: workspace.path,
301
+ name: workspace.name,
302
+ workspace,
198
303
  workspaceRoot,
199
304
  alreadyInWorkspace: true,
200
305
  });
@@ -211,11 +316,22 @@ async function handleImport(body, sendJson) {
211
316
  errorOnExist: true,
212
317
  dereference: false,
213
318
  });
319
+ const workspace = upsertWorkspace({
320
+ path: targetPath,
321
+ name: path.basename(targetPath),
322
+ source: 'managed',
323
+ managed: true,
324
+ }, {
325
+ workspaceRoot,
326
+ source: 'workspace:import',
327
+ });
328
+ ensureProjectMemory(workspace, 'workspace:import');
214
329
 
215
330
  sendJson(200, {
216
331
  success: true,
217
- path: targetPath,
218
- name: path.basename(targetPath),
332
+ path: workspace.path,
333
+ name: workspace.name,
334
+ workspace,
219
335
  workspaceRoot,
220
336
  alreadyInWorkspace: false,
221
337
  });
@@ -247,7 +363,17 @@ async function handleClone(body, sendJson) {
247
363
  try {
248
364
  await fs.promises.access(targetPath);
249
365
  if (await isGitRepo(targetPath)) {
250
- sendJson(200, { success: true, path: targetPath, workspaceRoot, alreadyExists: true });
366
+ const workspace = upsertWorkspace({
367
+ path: targetPath,
368
+ name: repoName,
369
+ source: 'github',
370
+ managed: true,
371
+ }, {
372
+ workspaceRoot,
373
+ source: 'workspace:clone',
374
+ });
375
+ ensureProjectMemory(workspace, 'workspace:clone');
376
+ sendJson(200, { success: true, path: workspace.path, workspace, workspaceRoot, alreadyExists: true });
251
377
  return;
252
378
  }
253
379
  sendJson(409, {
@@ -273,7 +399,18 @@ async function handleClone(body, sendJson) {
273
399
  return;
274
400
  }
275
401
 
276
- sendJson(200, { success: true, path: targetPath, workspaceRoot, alreadyExists: false });
402
+ const workspace = upsertWorkspace({
403
+ path: targetPath,
404
+ name: repoName,
405
+ source: 'github',
406
+ managed: true,
407
+ }, {
408
+ workspaceRoot,
409
+ source: 'workspace:clone',
410
+ });
411
+ ensureProjectMemory(workspace, 'workspace:clone');
412
+
413
+ sendJson(200, { success: true, path: workspace.path, workspace, workspaceRoot, alreadyExists: false });
277
414
  }
278
415
 
279
416
  async function handleBranches(query, sendJson) {
@@ -290,7 +427,7 @@ async function handleBranches(query, sendJson) {
290
427
 
291
428
  const remoteResult = await runGit(['remote'], workspacePath);
292
429
  const hasRemote = remoteResult.stdout.trim().length > 0;
293
- if (hasRemote) {
430
+ if (hasRemote && shouldRefreshBranches(query)) {
294
431
  await runGit(['fetch', '--prune'], workspacePath);
295
432
  }
296
433
 
@@ -486,6 +623,9 @@ async function handleWorktreeDelete(body, sendJson) {
486
623
 
487
624
  module.exports = {
488
625
  handleProjects,
626
+ handleProjectRegister,
627
+ handleProjectUpdate,
628
+ handleProjectDelete,
489
629
  handleImport,
490
630
  handleClone,
491
631
  handleBranches,
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { AMALGM_COMPUTER_ID } = require('../config');
7
+ const { openLocalDb } = require('../state/db');
8
+ const { insertStateEvent, publishStateEvent } = require('../state/events');
9
+
10
+ function cleanString(value) {
11
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
12
+ }
13
+
14
+ function stableComputerId(input) {
15
+ return cleanString(input) || cleanString(AMALGM_COMPUTER_ID) || 'local-computer';
16
+ }
17
+
18
+ function encodePathForUri(localPath) {
19
+ return localPath
20
+ .split(path.sep)
21
+ .map((segment) => encodeURIComponent(segment))
22
+ .join('/');
23
+ }
24
+
25
+ function workspaceUriForPath(localPath, computerId) {
26
+ const normalizedPath = path.resolve(localPath);
27
+ return `computer://${encodeURIComponent(stableComputerId(computerId))}${encodePathForUri(normalizedPath)}`;
28
+ }
29
+
30
+ function workspaceIdForUri(uri) {
31
+ return `ws_${crypto.createHash('sha256').update(uri).digest('hex').slice(0, 20)}`;
32
+ }
33
+
34
+ function isWithin(root, target) {
35
+ const relative = path.relative(root, target);
36
+ return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
37
+ }
38
+
39
+ function isManagedWorkspacePath(localPath, workspaceRoot) {
40
+ const root = cleanString(workspaceRoot);
41
+ if (!root) return false;
42
+ return isWithin(path.resolve(root), path.resolve(localPath));
43
+ }
44
+
45
+ function basename(localPath) {
46
+ return path.basename(String(localPath || '').replace(/\/+$/, '')) || 'workspace';
47
+ }
48
+
49
+ function nowIso() {
50
+ return new Date().toISOString();
51
+ }
52
+
53
+ function parseJson(value, fallback) {
54
+ if (typeof value !== 'string' || !value) return fallback;
55
+ try {
56
+ return JSON.parse(value);
57
+ } catch {
58
+ return fallback;
59
+ }
60
+ }
61
+
62
+ function normalizeWorkspaceRecord(input, options = {}) {
63
+ const localPath = cleanString(input.path || input.localPath || input.local_path);
64
+ if (!localPath || !path.isAbsolute(localPath)) {
65
+ throw new Error('Workspace path must be an absolute local path');
66
+ }
67
+
68
+ const resolvedPath = path.resolve(localPath);
69
+ const computerId = stableComputerId(input.computerId || input.computer_id || options.computerId);
70
+ const uri = cleanString(input.uri) || workspaceUriForPath(resolvedPath, computerId);
71
+ const id = cleanString(input.id) || workspaceIdForUri(uri);
72
+ const createdAt = cleanString(input.createdAt || input.created_at) || nowIso();
73
+ const updatedAt = nowIso();
74
+ const managed = input.managed !== undefined
75
+ ? Boolean(input.managed)
76
+ : isManagedWorkspacePath(resolvedPath, options.workspaceRoot);
77
+ const status = cleanString(input.status) || (fs.existsSync(resolvedPath) ? 'ready' : 'missing');
78
+ const source = cleanString(input.source) || (managed ? 'managed' : 'computer');
79
+ const name = cleanString(input.name) || basename(resolvedPath);
80
+
81
+ return {
82
+ id,
83
+ name,
84
+ uri,
85
+ path: resolvedPath,
86
+ localPath: resolvedPath,
87
+ locationType: 'computer',
88
+ computerId,
89
+ source,
90
+ managed,
91
+ status,
92
+ createdAt,
93
+ updatedAt,
94
+ location: {
95
+ type: 'computer',
96
+ computerId,
97
+ path: resolvedPath,
98
+ },
99
+ };
100
+ }
101
+
102
+ function rowToWorkspace(row) {
103
+ if (!row) return null;
104
+ const parsed = parseJson(row.workspace_json, {});
105
+ return {
106
+ ...parsed,
107
+ id: row.id,
108
+ name: row.name,
109
+ uri: row.uri,
110
+ path: row.path,
111
+ localPath: row.path,
112
+ locationType: row.location_type,
113
+ computerId: row.computer_id || parsed.computerId || null,
114
+ source: row.source,
115
+ managed: row.managed === 1,
116
+ status: row.status,
117
+ createdAt: row.created_at,
118
+ updatedAt: row.updated_at,
119
+ };
120
+ }
121
+
122
+ function publishWorkspaceEvents(database, op, workspace, source) {
123
+ const events = [
124
+ insertStateEvent(database, {
125
+ resource: 'workspaces',
126
+ op,
127
+ id: workspace.id,
128
+ value: op === 'delete' ? undefined : workspace,
129
+ source,
130
+ }),
131
+ insertStateEvent(database, {
132
+ resource: 'projects',
133
+ op,
134
+ id: workspace.id,
135
+ value: op === 'delete' ? undefined : workspace,
136
+ source,
137
+ }),
138
+ ];
139
+ return events.filter(Boolean);
140
+ }
141
+
142
+ function publishEvents(events) {
143
+ for (const event of events) publishStateEvent(event);
144
+ }
145
+
146
+ function listWorkspaces() {
147
+ return openLocalDb()
148
+ .prepare('SELECT * FROM workspaces ORDER BY lower(name), path')
149
+ .all()
150
+ .map(rowToWorkspace)
151
+ .filter(Boolean);
152
+ }
153
+
154
+ function upsertWorkspace(input, options = {}) {
155
+ const db = openLocalDb();
156
+ let workspace;
157
+ let events = [];
158
+
159
+ db.transaction(() => {
160
+ workspace = normalizeWorkspaceRecord(input, options);
161
+ const existing = db
162
+ .prepare('SELECT * FROM workspaces WHERE id = ? OR uri = ? OR path = ? LIMIT 1')
163
+ .get(workspace.id, workspace.uri, workspace.path);
164
+ if (existing) {
165
+ const old = rowToWorkspace(existing);
166
+ workspace = {
167
+ ...old,
168
+ ...workspace,
169
+ id: old.id,
170
+ uri: old.uri || workspace.uri,
171
+ createdAt: old.createdAt || workspace.createdAt,
172
+ updatedAt: nowIso(),
173
+ };
174
+ }
175
+
176
+ db.prepare(`
177
+ INSERT INTO workspaces (
178
+ id,
179
+ name,
180
+ uri,
181
+ path,
182
+ location_type,
183
+ computer_id,
184
+ source,
185
+ managed,
186
+ status,
187
+ created_at,
188
+ updated_at,
189
+ workspace_json
190
+ )
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
192
+ ON CONFLICT(id) DO UPDATE SET
193
+ name = excluded.name,
194
+ uri = excluded.uri,
195
+ path = excluded.path,
196
+ location_type = excluded.location_type,
197
+ computer_id = excluded.computer_id,
198
+ source = excluded.source,
199
+ managed = excluded.managed,
200
+ status = excluded.status,
201
+ updated_at = excluded.updated_at,
202
+ workspace_json = excluded.workspace_json
203
+ `).run(
204
+ workspace.id,
205
+ workspace.name,
206
+ workspace.uri,
207
+ workspace.path,
208
+ workspace.locationType,
209
+ workspace.computerId,
210
+ workspace.source,
211
+ workspace.managed ? 1 : 0,
212
+ workspace.status,
213
+ workspace.createdAt,
214
+ workspace.updatedAt,
215
+ JSON.stringify(workspace),
216
+ );
217
+
218
+ events = publishWorkspaceEvents(db, existing ? 'update' : 'insert', workspace, options.source || 'workspace:upsert');
219
+ })();
220
+
221
+ publishEvents(events);
222
+ return workspace;
223
+ }
224
+
225
+ function renameWorkspace(identifier, name, options = {}) {
226
+ const trimmedName = cleanString(name);
227
+ if (!trimmedName) throw new Error('Workspace name is required');
228
+
229
+ const db = openLocalDb();
230
+ let workspace = null;
231
+ let events = [];
232
+
233
+ db.transaction(() => {
234
+ const row = db.prepare('SELECT * FROM workspaces WHERE id = ? OR path = ? LIMIT 1').get(identifier, identifier);
235
+ if (!row) throw new Error('Workspace not found');
236
+ workspace = {
237
+ ...rowToWorkspace(row),
238
+ name: trimmedName,
239
+ updatedAt: nowIso(),
240
+ };
241
+ db.prepare(`
242
+ UPDATE workspaces
243
+ SET name = ?, updated_at = ?, workspace_json = ?
244
+ WHERE id = ?
245
+ `).run(workspace.name, workspace.updatedAt, JSON.stringify(workspace), workspace.id);
246
+ events = publishWorkspaceEvents(db, 'update', workspace, options.source || 'workspace:rename');
247
+ })();
248
+
249
+ publishEvents(events);
250
+ return workspace;
251
+ }
252
+
253
+ function removeWorkspace(identifier, options = {}) {
254
+ const db = openLocalDb();
255
+ let workspace = null;
256
+ let events = [];
257
+
258
+ db.transaction(() => {
259
+ const row = db.prepare('SELECT * FROM workspaces WHERE id = ? OR path = ? LIMIT 1').get(identifier, identifier);
260
+ if (!row) return;
261
+ workspace = rowToWorkspace(row);
262
+ db.prepare('DELETE FROM workspaces WHERE id = ?').run(workspace.id);
263
+ events = publishWorkspaceEvents(db, 'delete', workspace, options.source || 'workspace:remove');
264
+ })();
265
+
266
+ publishEvents(events);
267
+ return workspace;
268
+ }
269
+
270
+ module.exports = {
271
+ listWorkspaces,
272
+ normalizeWorkspaceRecord,
273
+ removeWorkspace,
274
+ renameWorkspace,
275
+ upsertWorkspace,
276
+ workspaceIdForUri,
277
+ workspaceUriForPath,
278
+ };
@@ -19,6 +19,8 @@ class JsonLineRpc {
19
19
  this.pending = new Map();
20
20
  this.handlers = new Set();
21
21
  this.buffer = '';
22
+ this.closed = false;
23
+ this.closeError = null;
22
24
  this.child = spawn(binary, ['app-server'], {
23
25
  cwd,
24
26
  env,
@@ -33,13 +35,32 @@ class JsonLineRpc {
33
35
  const text = String(chunk || '').trim();
34
36
  if (text && process.env.CHAT_CORE_DEBUG) console.warn('[Codex]', text.slice(0, 1000));
35
37
  });
38
+ this.child.on('error', (err) => {
39
+ this.failAll(err);
40
+ });
36
41
  this.child.on('exit', (code, signal) => {
37
42
  const err = new Error(`codex app-server exited (${code ?? signal ?? 'unknown'})`);
38
- for (const pending of this.pending.values()) pending.reject(err);
39
- this.pending.clear();
43
+ this.failAll(err);
44
+ });
45
+ this.child.stdin.on('error', (err) => {
46
+ this.failAll(err);
40
47
  });
41
48
  }
42
49
 
50
+ failAll(err) {
51
+ this.closed = true;
52
+ this.closeError = err;
53
+ for (const pending of this.pending.values()) pending.reject(err);
54
+ this.pending.clear();
55
+ }
56
+
57
+ rejectPending(id, err) {
58
+ const pending = this.pending.get(id);
59
+ if (!pending) return;
60
+ this.pending.delete(id);
61
+ pending.reject(err);
62
+ }
63
+
43
64
  onData(chunk) {
44
65
  this.buffer += chunk;
45
66
  const lines = this.buffer.split('\n');
@@ -64,8 +85,12 @@ class JsonLineRpc {
64
85
  }
65
86
 
66
87
  request(method, params = {}, timeoutMs = 180000) {
88
+ if (this.closeError) return Promise.reject(this.closeError);
89
+ if (this.closed || this.child.exitCode !== null || this.child.signalCode !== null) {
90
+ return Promise.reject(new Error('codex app-server is not running'));
91
+ }
67
92
  const id = this.id++;
68
- this.child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
93
+ const payload = `${JSON.stringify({ id, method, params })}\n`;
69
94
  return new Promise((resolve, reject) => {
70
95
  const timer = setTimeout(() => {
71
96
  this.pending.delete(id);
@@ -75,11 +100,26 @@ class JsonLineRpc {
75
100
  resolve: (value) => { clearTimeout(timer); resolve(value); },
76
101
  reject: (err) => { clearTimeout(timer); reject(err); },
77
102
  });
103
+ try {
104
+ this.child.stdin.write(payload, (err) => {
105
+ if (err) this.rejectPending(id, err);
106
+ });
107
+ } catch (err) {
108
+ this.rejectPending(id, err);
109
+ }
78
110
  });
79
111
  }
80
112
 
81
113
  notify(method, params) {
82
- this.child.stdin.write(`${JSON.stringify(params === undefined ? { method } : { method, params })}\n`);
114
+ if (this.closed || this.child.exitCode !== null || this.child.signalCode !== null) return;
115
+ const payload = `${JSON.stringify(params === undefined ? { method } : { method, params })}\n`;
116
+ try {
117
+ this.child.stdin.write(payload, (err) => {
118
+ if (err) this.failAll(err);
119
+ });
120
+ } catch (err) {
121
+ this.failAll(err);
122
+ }
83
123
  }
84
124
 
85
125
  on(handler) {