@cluesmith/codev 2.0.0-rc.61 → 2.0.0-rc.63
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/dashboard/dist/assets/index-C7FtNK6Y.css +32 -0
- package/dashboard/dist/assets/index-D6VqWAaI.js +131 -0
- package/dashboard/dist/assets/index-D6VqWAaI.js.map +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +75 -50
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/architect.d.ts.map +1 -1
- package/dist/agent-farm/commands/architect.js +38 -48
- package/dist/agent-farm/commands/architect.js.map +1 -1
- package/dist/agent-farm/commands/attach.d.ts.map +1 -1
- package/dist/agent-farm/commands/attach.js +14 -35
- package/dist/agent-farm/commands/attach.js.map +1 -1
- package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
- package/dist/agent-farm/commands/cleanup.js +17 -18
- package/dist/agent-farm/commands/cleanup.js.map +1 -1
- package/dist/agent-farm/commands/consult.d.ts +3 -4
- package/dist/agent-farm/commands/consult.d.ts.map +1 -1
- package/dist/agent-farm/commands/consult.js +27 -37
- package/dist/agent-farm/commands/consult.js.map +1 -1
- package/dist/agent-farm/commands/open.d.ts.map +1 -1
- package/dist/agent-farm/commands/open.js +17 -31
- package/dist/agent-farm/commands/open.js.map +1 -1
- package/dist/agent-farm/commands/shell.d.ts.map +1 -1
- package/dist/agent-farm/commands/shell.js +27 -38
- package/dist/agent-farm/commands/shell.js.map +1 -1
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +113 -90
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/start.d.ts +7 -20
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +3 -242
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/commands/status.d.ts.map +1 -1
- package/dist/agent-farm/commands/status.js +22 -29
- package/dist/agent-farm/commands/status.js.map +1 -1
- package/dist/agent-farm/commands/stop.d.ts.map +1 -1
- package/dist/agent-farm/commands/stop.js +43 -172
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/commands/tower-cloud.d.ts +47 -0
- package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -0
- package/dist/agent-farm/commands/tower-cloud.js +316 -0
- package/dist/agent-farm/commands/tower-cloud.js.map +1 -0
- package/dist/agent-farm/db/index.d.ts +6 -2
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +56 -31
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/migrate.d.ts +0 -4
- package/dist/agent-farm/db/migrate.d.ts.map +1 -1
- package/dist/agent-farm/db/migrate.js +0 -46
- package/dist/agent-farm/db/migrate.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +3 -3
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +3 -17
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/db/types.d.ts +0 -10
- package/dist/agent-farm/db/types.d.ts.map +1 -1
- package/dist/agent-farm/db/types.js +0 -8
- package/dist/agent-farm/db/types.js.map +1 -1
- package/dist/agent-farm/hq-connector.d.ts +1 -1
- package/dist/agent-farm/hq-connector.js +1 -1
- package/dist/agent-farm/lib/cloud-config.d.ts +46 -0
- package/dist/agent-farm/lib/cloud-config.d.ts.map +1 -0
- package/dist/agent-farm/lib/cloud-config.js +106 -0
- package/dist/agent-farm/lib/cloud-config.js.map +1 -0
- package/dist/agent-farm/lib/tower-client.d.ts +7 -5
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tower-client.js.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.d.ts +117 -0
- package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -0
- package/dist/agent-farm/lib/tunnel-client.js +502 -0
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.js +559 -341
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/state.d.ts +2 -2
- package/dist/agent-farm/state.d.ts.map +1 -1
- package/dist/agent-farm/state.js +6 -16
- package/dist/agent-farm/state.js.map +1 -1
- package/dist/agent-farm/types.d.ts +1 -18
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.d.ts +0 -5
- package/dist/agent-farm/utils/config.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.js +0 -31
- package/dist/agent-farm/utils/config.js.map +1 -1
- package/dist/agent-farm/utils/file-tabs.d.ts +27 -0
- package/dist/agent-farm/utils/file-tabs.d.ts.map +1 -0
- package/dist/agent-farm/utils/file-tabs.js +46 -0
- package/dist/agent-farm/utils/file-tabs.js.map +1 -0
- package/dist/agent-farm/utils/gate-status.d.ts +16 -0
- package/dist/agent-farm/utils/gate-status.d.ts.map +1 -0
- package/dist/agent-farm/utils/gate-status.js +79 -0
- package/dist/agent-farm/utils/gate-status.js.map +1 -0
- package/dist/agent-farm/utils/gate-watcher.d.ts +38 -0
- package/dist/agent-farm/utils/gate-watcher.d.ts.map +1 -0
- package/dist/agent-farm/utils/gate-watcher.js +122 -0
- package/dist/agent-farm/utils/gate-watcher.js.map +1 -0
- package/dist/agent-farm/utils/index.d.ts +0 -1
- package/dist/agent-farm/utils/index.d.ts.map +1 -1
- package/dist/agent-farm/utils/index.js +0 -1
- package/dist/agent-farm/utils/index.js.map +1 -1
- package/dist/agent-farm/utils/notifications.js +1 -1
- package/dist/agent-farm/utils/notifications.js.map +1 -1
- package/dist/agent-farm/utils/server-utils.d.ts +1 -1
- package/dist/agent-farm/utils/server-utils.js +1 -1
- package/dist/agent-farm/utils/session.d.ts +10 -0
- package/dist/agent-farm/utils/session.d.ts.map +1 -0
- package/dist/agent-farm/utils/session.js +12 -0
- package/dist/agent-farm/utils/session.js.map +1 -0
- package/dist/commands/adopt.js +1 -1
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +23 -14
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +35 -12
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/next.js +11 -3
- package/dist/commands/porch/next.js.map +1 -1
- package/dist/commands/porch/verdict.d.ts +8 -0
- package/dist/commands/porch/verdict.d.ts.map +1 -1
- package/dist/commands/porch/verdict.js +13 -0
- package/dist/commands/porch/verdict.js.map +1 -1
- package/dist/terminal/pty-session.d.ts +2 -0
- package/dist/terminal/pty-session.d.ts.map +1 -1
- package/dist/terminal/pty-session.js +4 -0
- package/dist/terminal/pty-session.js.map +1 -1
- package/package.json +1 -1
- package/skeleton/.claude/skills/af/SKILL.md +15 -0
- package/skeleton/protocols/spir/prompts/review.md +15 -16
- package/skeleton/protocols/spir/protocol.json +4 -0
- package/skeleton/protocols/spir/templates/review.md +81 -199
- package/skeleton/resources/commands/agent-farm.md +38 -2
- package/templates/tower.html +7 -150
- package/dashboard/dist/assets/index-CXloFYpB.css +0 -32
- package/dashboard/dist/assets/index-Ca2fjOJf.js +0 -131
- package/dashboard/dist/assets/index-Ca2fjOJf.js.map +0 -1
- package/dist/agent-farm/utils/orphan-handler.d.ts +0 -27
- package/dist/agent-farm/utils/orphan-handler.d.ts.map +0 -1
- package/dist/agent-farm/utils/orphan-handler.js +0 -149
- package/dist/agent-farm/utils/orphan-handler.js.map +0 -1
- package/dist/agent-farm/utils/port-registry.d.ts +0 -57
- package/dist/agent-farm/utils/port-registry.d.ts.map +0 -1
- package/dist/agent-farm/utils/port-registry.js +0 -166
- package/dist/agent-farm/utils/port-registry.js.map +0 -1
- package/dist/agent-farm/utils/terminal-ports.d.ts +0 -18
- package/dist/agent-farm/utils/terminal-ports.d.ts.map +0 -1
- package/dist/agent-farm/utils/terminal-ports.js +0 -35
- package/dist/agent-farm/utils/terminal-ports.js.map +0 -1
|
@@ -6,18 +6,21 @@
|
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import net from 'node:net';
|
|
10
9
|
import crypto from 'node:crypto';
|
|
11
|
-
import {
|
|
10
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
12
11
|
import { homedir, tmpdir } from 'node:os';
|
|
13
12
|
import { fileURLToPath } from 'node:url';
|
|
14
13
|
import { Command } from 'commander';
|
|
15
14
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
16
15
|
import { getGlobalDb } from '../db/index.js';
|
|
17
|
-
import { cleanupStaleEntries } from '../utils/port-registry.js';
|
|
18
16
|
import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
|
|
17
|
+
import { getGateStatusForProject } from '../utils/gate-status.js';
|
|
18
|
+
import { GateWatcher } from '../utils/gate-watcher.js';
|
|
19
|
+
import { saveFileTab as saveFileTabToDb, deleteFileTab as deleteFileTabFromDb, loadFileTabsForProject as loadFileTabsFromDb, } from '../utils/file-tabs.js';
|
|
19
20
|
import { TerminalManager } from '../../terminal/pty-manager.js';
|
|
20
21
|
import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
|
|
22
|
+
import { TunnelClient } from '../lib/tunnel-client.js';
|
|
23
|
+
import { readCloudConfig, getCloudConfigPath, maskApiKey } from '../lib/cloud-config.js';
|
|
21
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
25
|
const __dirname = path.dirname(__filename);
|
|
23
26
|
// Default port for tower dashboard
|
|
@@ -59,6 +62,178 @@ function cleanupRateLimits() {
|
|
|
59
62
|
// Cleanup stale rate limit entries every 5 minutes
|
|
60
63
|
setInterval(cleanupRateLimits, 5 * 60 * 1000);
|
|
61
64
|
// ============================================================================
|
|
65
|
+
// Cloud Tunnel Client (Spec 0097 Phase 4)
|
|
66
|
+
// ============================================================================
|
|
67
|
+
/** Tunnel client instance — created on startup or via POST /api/tunnel/connect */
|
|
68
|
+
let tunnelClient = null;
|
|
69
|
+
/** Config file watcher — watches cloud-config.json for changes */
|
|
70
|
+
let configWatcher = null;
|
|
71
|
+
/** Debounce timer for config file watcher events */
|
|
72
|
+
let configWatchDebounce = null;
|
|
73
|
+
/** Default tunnel port for codevos.ai */
|
|
74
|
+
// TICK-001: tunnelPort is no longer needed — WebSocket connects on the same port
|
|
75
|
+
/** Periodic metadata refresh interval (re-sends metadata to codevos.ai) */
|
|
76
|
+
let metadataRefreshInterval = null;
|
|
77
|
+
/** Metadata refresh period in milliseconds (30 seconds) */
|
|
78
|
+
const METADATA_REFRESH_MS = 30_000;
|
|
79
|
+
/**
|
|
80
|
+
* Gather current tower metadata (projects + terminals) for codevos.ai.
|
|
81
|
+
*/
|
|
82
|
+
async function gatherMetadata() {
|
|
83
|
+
const instances = await getInstances();
|
|
84
|
+
const projects = instances.map((i) => ({
|
|
85
|
+
path: i.projectPath,
|
|
86
|
+
name: i.projectName,
|
|
87
|
+
}));
|
|
88
|
+
// Build reverse mapping: terminal ID → project path
|
|
89
|
+
const terminalToProject = new Map();
|
|
90
|
+
for (const [projectPath, entry] of projectTerminals) {
|
|
91
|
+
if (entry.architect)
|
|
92
|
+
terminalToProject.set(entry.architect, projectPath);
|
|
93
|
+
for (const termId of entry.builders.values())
|
|
94
|
+
terminalToProject.set(termId, projectPath);
|
|
95
|
+
for (const termId of entry.shells.values())
|
|
96
|
+
terminalToProject.set(termId, projectPath);
|
|
97
|
+
}
|
|
98
|
+
const manager = terminalManager;
|
|
99
|
+
const terminals = [];
|
|
100
|
+
if (manager) {
|
|
101
|
+
for (const session of manager.listSessions()) {
|
|
102
|
+
terminals.push({
|
|
103
|
+
id: session.id,
|
|
104
|
+
projectPath: terminalToProject.get(session.id) ?? '',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { projects, terminals };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Start periodic metadata refresh — re-gathers metadata and pushes to codevos.ai
|
|
112
|
+
* every METADATA_REFRESH_MS while the tunnel is connected.
|
|
113
|
+
*/
|
|
114
|
+
function startMetadataRefresh() {
|
|
115
|
+
stopMetadataRefresh();
|
|
116
|
+
metadataRefreshInterval = setInterval(async () => {
|
|
117
|
+
try {
|
|
118
|
+
if (tunnelClient && tunnelClient.getState() === 'connected') {
|
|
119
|
+
const metadata = await gatherMetadata();
|
|
120
|
+
tunnelClient.sendMetadata(metadata);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
log('WARN', `Metadata refresh failed: ${err.message}`);
|
|
125
|
+
}
|
|
126
|
+
}, METADATA_REFRESH_MS);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Stop the periodic metadata refresh.
|
|
130
|
+
*/
|
|
131
|
+
function stopMetadataRefresh() {
|
|
132
|
+
if (metadataRefreshInterval) {
|
|
133
|
+
clearInterval(metadataRefreshInterval);
|
|
134
|
+
metadataRefreshInterval = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Create or reconnect the tunnel client using the given config.
|
|
139
|
+
* Sets up state change listeners and sends initial metadata.
|
|
140
|
+
*/
|
|
141
|
+
async function connectTunnel(config) {
|
|
142
|
+
// Disconnect existing client if any
|
|
143
|
+
if (tunnelClient) {
|
|
144
|
+
tunnelClient.disconnect();
|
|
145
|
+
}
|
|
146
|
+
const client = new TunnelClient({
|
|
147
|
+
serverUrl: config.server_url,
|
|
148
|
+
apiKey: config.api_key,
|
|
149
|
+
towerId: config.tower_id,
|
|
150
|
+
localPort: port,
|
|
151
|
+
});
|
|
152
|
+
client.onStateChange((state, prev) => {
|
|
153
|
+
log('INFO', `Tunnel: ${prev} → ${state}`);
|
|
154
|
+
if (state === 'connected') {
|
|
155
|
+
startMetadataRefresh();
|
|
156
|
+
}
|
|
157
|
+
else if (prev === 'connected') {
|
|
158
|
+
stopMetadataRefresh();
|
|
159
|
+
}
|
|
160
|
+
if (state === 'auth_failed') {
|
|
161
|
+
log('ERROR', 'Cloud connection failed: API key is invalid or revoked. Run \'af tower register --reauth\' to update credentials.');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
// Gather and set initial metadata before connecting
|
|
165
|
+
const metadata = await gatherMetadata();
|
|
166
|
+
client.sendMetadata(metadata);
|
|
167
|
+
tunnelClient = client;
|
|
168
|
+
client.connect();
|
|
169
|
+
// Ensure config watcher is running — the config directory now exists.
|
|
170
|
+
// Handles the case where Tower booted before registration (directory didn't
|
|
171
|
+
// exist, so startConfigWatcher() silently failed at boot time).
|
|
172
|
+
startConfigWatcher();
|
|
173
|
+
return client;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Start watching cloud-config.json for changes.
|
|
177
|
+
* On change: reconnect with new credentials.
|
|
178
|
+
* On delete: disconnect tunnel.
|
|
179
|
+
*/
|
|
180
|
+
function startConfigWatcher() {
|
|
181
|
+
stopConfigWatcher();
|
|
182
|
+
const configPath = getCloudConfigPath();
|
|
183
|
+
const configDir = path.dirname(configPath);
|
|
184
|
+
const configFile = path.basename(configPath);
|
|
185
|
+
// Watch the directory (more reliable than watching the file directly)
|
|
186
|
+
try {
|
|
187
|
+
configWatcher = fs.watch(configDir, (eventType, filename) => {
|
|
188
|
+
if (filename !== configFile)
|
|
189
|
+
return;
|
|
190
|
+
// Debounce: multiple events fire for a single write
|
|
191
|
+
if (configWatchDebounce)
|
|
192
|
+
clearTimeout(configWatchDebounce);
|
|
193
|
+
configWatchDebounce = setTimeout(async () => {
|
|
194
|
+
configWatchDebounce = null;
|
|
195
|
+
try {
|
|
196
|
+
const config = readCloudConfig();
|
|
197
|
+
if (config) {
|
|
198
|
+
log('INFO', `Cloud config changed, reconnecting tunnel (key: ${maskApiKey(config.api_key)})`);
|
|
199
|
+
// Reset circuit breaker in case previous key was invalid
|
|
200
|
+
if (tunnelClient)
|
|
201
|
+
tunnelClient.resetCircuitBreaker();
|
|
202
|
+
await connectTunnel(config);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// Config deleted or invalid
|
|
206
|
+
log('INFO', 'Cloud config removed or invalid, disconnecting tunnel');
|
|
207
|
+
if (tunnelClient) {
|
|
208
|
+
tunnelClient.disconnect();
|
|
209
|
+
tunnelClient = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
log('WARN', `Error handling config change: ${err.message}`);
|
|
215
|
+
}
|
|
216
|
+
}, 500);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Directory doesn't exist yet — that's fine, user hasn't registered
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Stop watching cloud-config.json.
|
|
225
|
+
*/
|
|
226
|
+
function stopConfigWatcher() {
|
|
227
|
+
if (configWatcher) {
|
|
228
|
+
configWatcher.close();
|
|
229
|
+
configWatcher = null;
|
|
230
|
+
}
|
|
231
|
+
if (configWatchDebounce) {
|
|
232
|
+
clearTimeout(configWatchDebounce);
|
|
233
|
+
configWatchDebounce = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// ============================================================================
|
|
62
237
|
// PHASE 2 & 4: Terminal Management (Spec 0090)
|
|
63
238
|
// ============================================================================
|
|
64
239
|
// Global TerminalManager instance for tower-managed terminals
|
|
@@ -66,12 +241,14 @@ setInterval(cleanupRateLimits, 5 * 60 * 1000);
|
|
|
66
241
|
let terminalManager = null;
|
|
67
242
|
const projectTerminals = new Map();
|
|
68
243
|
/**
|
|
69
|
-
* Get or create project terminal registry entry
|
|
244
|
+
* Get or create project terminal registry entry.
|
|
245
|
+
* On first access for a project, hydrates file tabs from SQLite so
|
|
246
|
+
* persisted tabs are available immediately (not just after /api/state).
|
|
70
247
|
*/
|
|
71
248
|
function getProjectTerminalsEntry(projectPath) {
|
|
72
249
|
let entry = projectTerminals.get(projectPath);
|
|
73
250
|
if (!entry) {
|
|
74
|
-
entry = { builders: new Map(), shells: new Map(), fileTabs:
|
|
251
|
+
entry = { builders: new Map(), shells: new Map(), fileTabs: loadFileTabsForProject(projectPath) };
|
|
75
252
|
projectTerminals.set(projectPath, entry);
|
|
76
253
|
}
|
|
77
254
|
// Migration: ensure fileTabs exists for older entries
|
|
@@ -204,6 +381,45 @@ function deleteProjectTerminalSessions(projectPath) {
|
|
|
204
381
|
log('WARN', `Failed to delete project terminal sessions: ${err.message}`);
|
|
205
382
|
}
|
|
206
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Save a file tab to SQLite for persistence across Tower restarts.
|
|
386
|
+
* Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
|
|
387
|
+
*/
|
|
388
|
+
function saveFileTab(id, projectPath, filePath, createdAt) {
|
|
389
|
+
try {
|
|
390
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
391
|
+
saveFileTabToDb(getGlobalDb(), id, normalizedPath, filePath, createdAt);
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
log('WARN', `Failed to save file tab: ${err.message}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Delete a file tab from SQLite.
|
|
399
|
+
* Thin wrapper around utils/file-tabs.ts with error handling.
|
|
400
|
+
*/
|
|
401
|
+
function deleteFileTab(id) {
|
|
402
|
+
try {
|
|
403
|
+
deleteFileTabFromDb(getGlobalDb(), id);
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
log('WARN', `Failed to delete file tab: ${err.message}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Load file tabs for a project from SQLite.
|
|
411
|
+
* Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
|
|
412
|
+
*/
|
|
413
|
+
function loadFileTabsForProject(projectPath) {
|
|
414
|
+
try {
|
|
415
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
416
|
+
return loadFileTabsFromDb(getGlobalDb(), normalizedPath);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
log('WARN', `Failed to load file tabs: ${err.message}`);
|
|
420
|
+
}
|
|
421
|
+
return new Map();
|
|
422
|
+
}
|
|
207
423
|
// Whether tmux is available on this system (checked once at startup)
|
|
208
424
|
let tmuxAvailable = false;
|
|
209
425
|
/**
|
|
@@ -254,11 +470,13 @@ function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
|
|
|
254
470
|
log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
|
|
255
471
|
return null;
|
|
256
472
|
}
|
|
257
|
-
// Hide tmux status bar (dashboard has its own tabs)
|
|
258
|
-
//
|
|
473
|
+
// Hide tmux status bar (dashboard has its own tabs) and enable mouse.
|
|
474
|
+
// NOTE: aggressive-resize was removed — it caused resize bouncing and
|
|
475
|
+
// visual flashing (dots/redraws) when the dashboard sent multiple resize
|
|
476
|
+
// events during layout settling. Default tmux behavior (size to smallest
|
|
477
|
+
// client) is more stable since we only have one client per session.
|
|
259
478
|
spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
|
|
260
479
|
spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
|
|
261
|
-
spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
|
|
262
480
|
return sessionName;
|
|
263
481
|
}
|
|
264
482
|
catch (err) {
|
|
@@ -381,20 +599,14 @@ function findSqliteRowForTmuxSession(tmuxName) {
|
|
|
381
599
|
}
|
|
382
600
|
/**
|
|
383
601
|
* Find the full project path for a tmux session's project basename.
|
|
384
|
-
* Checks
|
|
602
|
+
* Checks known projects (terminal_sessions + in-memory cache) for a matching basename.
|
|
385
603
|
* Returns null if no match found.
|
|
386
604
|
*/
|
|
387
605
|
function resolveProjectPathFromBasename(projectBasename) {
|
|
388
|
-
const
|
|
389
|
-
for (const
|
|
390
|
-
if (path.basename(alloc.project_path) === projectBasename) {
|
|
391
|
-
return normalizeProjectPath(alloc.project_path);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
// Also check projectTerminals cache (may have entries not yet in allocations)
|
|
395
|
-
for (const [projectPath] of projectTerminals) {
|
|
606
|
+
const knownPaths = getKnownProjectPaths();
|
|
607
|
+
for (const projectPath of knownPaths) {
|
|
396
608
|
if (path.basename(projectPath) === projectBasename) {
|
|
397
|
-
return projectPath;
|
|
609
|
+
return normalizeProjectPath(projectPath);
|
|
398
610
|
}
|
|
399
611
|
}
|
|
400
612
|
return null;
|
|
@@ -402,7 +614,20 @@ function resolveProjectPathFromBasename(projectBasename) {
|
|
|
402
614
|
/**
|
|
403
615
|
* Reconcile terminal sessions on startup.
|
|
404
616
|
*
|
|
405
|
-
* STRATEGY
|
|
617
|
+
* DUAL-SOURCE STRATEGY (tmux + SQLite):
|
|
618
|
+
*
|
|
619
|
+
* tmux is the source of truth for LIVENESS (process existence).
|
|
620
|
+
* SQLite is the source of truth for METADATA (project association, type, role ID).
|
|
621
|
+
*
|
|
622
|
+
* This is intentional: tmux sessions survive Tower restarts because they are
|
|
623
|
+
* OS-level processes independent of Tower. SQLite rows, on the other hand,
|
|
624
|
+
* cannot track process liveness — a row may exist for a terminal whose process
|
|
625
|
+
* has long since exited. Therefore:
|
|
626
|
+
* - We NEVER trust SQLite alone to determine if a terminal is running.
|
|
627
|
+
* - We ALWAYS check tmux for liveness, then use SQLite for enrichment.
|
|
628
|
+
*
|
|
629
|
+
* File tabs are the exception: they have no backing process, so SQLite is
|
|
630
|
+
* the sole source of truth for their persistence (see file_tabs table).
|
|
406
631
|
*
|
|
407
632
|
* Phase 1 — tmux-first discovery:
|
|
408
633
|
* List all codev tmux sessions. For each, look up SQLite for metadata.
|
|
@@ -437,6 +662,23 @@ async function reconcileTerminalSessions() {
|
|
|
437
662
|
log('WARN', `Cannot resolve project path for tmux session "${tmuxName}" (basename: ${parsed.projectBasename}) — skipping`);
|
|
438
663
|
continue;
|
|
439
664
|
}
|
|
665
|
+
// Skip sessions whose project path doesn't exist on disk or is in a
|
|
666
|
+
// temp directory (left over from E2E tests that share global.db/tmux).
|
|
667
|
+
if (!fs.existsSync(projectPath)) {
|
|
668
|
+
log('INFO', `Skipping tmux "${tmuxName}" — project path no longer exists: ${projectPath}`);
|
|
669
|
+
killTmuxSession(tmuxName);
|
|
670
|
+
if (dbRow)
|
|
671
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const tmpDirs = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders'];
|
|
675
|
+
if (tmpDirs.some(d => projectPath.startsWith(d))) {
|
|
676
|
+
log('INFO', `Skipping tmux "${tmuxName}" — project is in temp directory: ${projectPath}`);
|
|
677
|
+
killTmuxSession(tmuxName);
|
|
678
|
+
if (dbRow)
|
|
679
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
440
682
|
try {
|
|
441
683
|
const label = type === 'architect' ? 'Architect' : `${type} ${roleId || 'unknown'}`;
|
|
442
684
|
const newSession = await manager.createSession({
|
|
@@ -668,8 +910,19 @@ async function gracefulShutdown(signal) {
|
|
|
668
910
|
log('INFO', 'Shutting down terminal manager...');
|
|
669
911
|
terminalManager.shutdown();
|
|
670
912
|
}
|
|
671
|
-
// 4. Stop
|
|
672
|
-
|
|
913
|
+
// 4. Stop gate watcher
|
|
914
|
+
if (gateWatcherInterval) {
|
|
915
|
+
clearInterval(gateWatcherInterval);
|
|
916
|
+
gateWatcherInterval = null;
|
|
917
|
+
}
|
|
918
|
+
// 5. Disconnect tunnel (Spec 0097 Phase 4)
|
|
919
|
+
stopMetadataRefresh();
|
|
920
|
+
stopConfigWatcher();
|
|
921
|
+
if (tunnelClient) {
|
|
922
|
+
log('INFO', 'Disconnecting tunnel...');
|
|
923
|
+
tunnelClient.disconnect();
|
|
924
|
+
tunnelClient = null;
|
|
925
|
+
}
|
|
673
926
|
log('INFO', 'Graceful shutdown complete');
|
|
674
927
|
process.exit(0);
|
|
675
928
|
}
|
|
@@ -682,38 +935,26 @@ if (isNaN(port) || port < 1 || port > 65535) {
|
|
|
682
935
|
}
|
|
683
936
|
log('INFO', `Tower server starting on port ${port}`);
|
|
684
937
|
/**
|
|
685
|
-
*
|
|
938
|
+
* Get all known project paths from terminal_sessions and in-memory cache
|
|
686
939
|
*/
|
|
687
|
-
function
|
|
940
|
+
function getKnownProjectPaths() {
|
|
941
|
+
const projectPaths = new Set();
|
|
942
|
+
// From terminal_sessions table (persists across Tower restarts)
|
|
688
943
|
try {
|
|
689
944
|
const db = getGlobalDb();
|
|
690
|
-
|
|
945
|
+
const sessions = db.prepare('SELECT DISTINCT project_path FROM terminal_sessions').all();
|
|
946
|
+
for (const s of sessions) {
|
|
947
|
+
projectPaths.add(s.project_path);
|
|
948
|
+
}
|
|
691
949
|
}
|
|
692
|
-
catch
|
|
693
|
-
|
|
694
|
-
return [];
|
|
950
|
+
catch {
|
|
951
|
+
// Table may not exist yet
|
|
695
952
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
return new Promise((resolve) => {
|
|
702
|
-
const socket = new net.Socket();
|
|
703
|
-
socket.setTimeout(1000);
|
|
704
|
-
socket.on('connect', () => {
|
|
705
|
-
socket.destroy();
|
|
706
|
-
resolve(true);
|
|
707
|
-
});
|
|
708
|
-
socket.on('timeout', () => {
|
|
709
|
-
socket.destroy();
|
|
710
|
-
resolve(false);
|
|
711
|
-
});
|
|
712
|
-
socket.on('error', () => {
|
|
713
|
-
resolve(false);
|
|
714
|
-
});
|
|
715
|
-
socket.connect(port, '127.0.0.1');
|
|
716
|
-
});
|
|
953
|
+
// From in-memory cache (includes projects activated this session)
|
|
954
|
+
for (const [projectPath] of projectTerminals) {
|
|
955
|
+
projectPaths.add(projectPath);
|
|
956
|
+
}
|
|
957
|
+
return Array.from(projectPaths);
|
|
717
958
|
}
|
|
718
959
|
/**
|
|
719
960
|
* Get project name from path
|
|
@@ -721,88 +962,22 @@ async function isPortListening(port) {
|
|
|
721
962
|
function getProjectName(projectPath) {
|
|
722
963
|
return path.basename(projectPath);
|
|
723
964
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
const
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
// Cloudflared tunnel management
|
|
743
|
-
let tunnelProcess = null;
|
|
744
|
-
let tunnelUrl = null;
|
|
745
|
-
function isCloudflaredInstalled() {
|
|
746
|
-
try {
|
|
747
|
-
execSync('which cloudflared', { stdio: 'ignore' });
|
|
748
|
-
return true;
|
|
749
|
-
}
|
|
750
|
-
catch {
|
|
751
|
-
return false;
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
function getTunnelStatus() {
|
|
755
|
-
return {
|
|
756
|
-
available: isCloudflaredInstalled(),
|
|
757
|
-
running: tunnelProcess !== null && tunnelUrl !== null,
|
|
758
|
-
url: tunnelUrl,
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
async function startTunnel(port) {
|
|
762
|
-
if (!isCloudflaredInstalled()) {
|
|
763
|
-
return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
|
|
764
|
-
}
|
|
765
|
-
if (tunnelProcess) {
|
|
766
|
-
return { success: true, url: tunnelUrl || undefined };
|
|
767
|
-
}
|
|
768
|
-
return new Promise((resolve) => {
|
|
769
|
-
tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
770
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
771
|
-
});
|
|
772
|
-
const handleOutput = (data) => {
|
|
773
|
-
const text = data.toString();
|
|
774
|
-
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
775
|
-
if (match && !tunnelUrl) {
|
|
776
|
-
tunnelUrl = match[0];
|
|
777
|
-
log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
|
|
778
|
-
resolve({ success: true, url: tunnelUrl });
|
|
779
|
-
}
|
|
780
|
-
};
|
|
781
|
-
tunnelProcess.stdout?.on('data', handleOutput);
|
|
782
|
-
tunnelProcess.stderr?.on('data', handleOutput);
|
|
783
|
-
tunnelProcess.on('close', (code) => {
|
|
784
|
-
log('INFO', `Cloudflared tunnel closed with code ${code}`);
|
|
785
|
-
tunnelProcess = null;
|
|
786
|
-
tunnelUrl = null;
|
|
787
|
-
});
|
|
788
|
-
// Timeout after 30 seconds
|
|
789
|
-
setTimeout(() => {
|
|
790
|
-
if (!tunnelUrl) {
|
|
791
|
-
tunnelProcess?.kill();
|
|
792
|
-
tunnelProcess = null;
|
|
793
|
-
resolve({ success: false, error: 'Tunnel startup timed out' });
|
|
794
|
-
}
|
|
795
|
-
}, 30000);
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
function stopTunnel() {
|
|
799
|
-
if (tunnelProcess) {
|
|
800
|
-
tunnelProcess.kill();
|
|
801
|
-
tunnelProcess = null;
|
|
802
|
-
tunnelUrl = null;
|
|
803
|
-
log('INFO', 'Cloudflared tunnel stopped');
|
|
804
|
-
}
|
|
805
|
-
return { success: true };
|
|
965
|
+
// Spec 0100: Gate watcher for af send notifications
|
|
966
|
+
const gateWatcher = new GateWatcher(log);
|
|
967
|
+
let gateWatcherInterval = null;
|
|
968
|
+
function startGateWatcher() {
|
|
969
|
+
gateWatcherInterval = setInterval(async () => {
|
|
970
|
+
const projectPaths = getKnownProjectPaths();
|
|
971
|
+
for (const projectPath of projectPaths) {
|
|
972
|
+
try {
|
|
973
|
+
const gateStatus = getGateStatusForProject(projectPath);
|
|
974
|
+
await gateWatcher.checkAndNotify(gateStatus, projectPath);
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
log('WARN', `Gate watcher error for ${projectPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}, 10_000);
|
|
806
981
|
}
|
|
807
982
|
const sseClients = [];
|
|
808
983
|
let notificationIdCounter = 0;
|
|
@@ -822,37 +997,6 @@ function broadcastNotification(notification) {
|
|
|
822
997
|
}
|
|
823
998
|
}
|
|
824
999
|
}
|
|
825
|
-
/**
|
|
826
|
-
* Get gate status for a project by querying its dashboard API.
|
|
827
|
-
* Uses timeout to prevent hung projects from stalling tower status.
|
|
828
|
-
*/
|
|
829
|
-
async function getGateStatusForProject(basePort) {
|
|
830
|
-
const controller = new AbortController();
|
|
831
|
-
const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
|
|
832
|
-
try {
|
|
833
|
-
const response = await fetch(`http://localhost:${basePort}/api/status`, {
|
|
834
|
-
signal: controller.signal,
|
|
835
|
-
});
|
|
836
|
-
clearTimeout(timeout);
|
|
837
|
-
if (!response.ok)
|
|
838
|
-
return { hasGate: false };
|
|
839
|
-
const projectStatus = await response.json();
|
|
840
|
-
// Check if any builder has a pending gate
|
|
841
|
-
const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
|
|
842
|
-
if (builderWithGate) {
|
|
843
|
-
return {
|
|
844
|
-
hasGate: true,
|
|
845
|
-
gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
|
|
846
|
-
builderId: builderWithGate.id,
|
|
847
|
-
timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
|
|
848
|
-
};
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
catch {
|
|
852
|
-
// Project dashboard not responding or timeout
|
|
853
|
-
}
|
|
854
|
-
return { hasGate: false };
|
|
855
|
-
}
|
|
856
1000
|
/**
|
|
857
1001
|
* Get terminal list for a project from tower's registry.
|
|
858
1002
|
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
|
|
@@ -870,11 +1014,15 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
870
1014
|
// Previous approach cleared the cache then rebuilt, which lost terminals
|
|
871
1015
|
// if their SQLite rows were deleted by external interference (e.g., tests).
|
|
872
1016
|
const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
|
|
873
|
-
//
|
|
1017
|
+
// Load file tabs from SQLite (persisted across restarts)
|
|
874
1018
|
const existingEntry = projectTerminals.get(normalizedPath);
|
|
875
|
-
if (existingEntry) {
|
|
1019
|
+
if (existingEntry && existingEntry.fileTabs.size > 0) {
|
|
1020
|
+
// Use in-memory state if already populated (avoids redundant DB reads)
|
|
876
1021
|
freshEntry.fileTabs = existingEntry.fileTabs;
|
|
877
1022
|
}
|
|
1023
|
+
else {
|
|
1024
|
+
freshEntry.fileTabs = loadFileTabsForProject(projectPath);
|
|
1025
|
+
}
|
|
878
1026
|
for (const dbSession of dbSessions) {
|
|
879
1027
|
// Verify session still exists in TerminalManager (runtime state)
|
|
880
1028
|
let session = manager.getSession(dbSession.id);
|
|
@@ -944,7 +1092,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
944
1092
|
if (existingEntry) {
|
|
945
1093
|
if (existingEntry.architect && !freshEntry.architect) {
|
|
946
1094
|
const session = manager.getSession(existingEntry.architect);
|
|
947
|
-
if (session) {
|
|
1095
|
+
if (session && session.status === 'running') {
|
|
948
1096
|
freshEntry.architect = existingEntry.architect;
|
|
949
1097
|
terminals.push({
|
|
950
1098
|
type: 'architect',
|
|
@@ -958,7 +1106,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
958
1106
|
for (const [builderId, terminalId] of existingEntry.builders) {
|
|
959
1107
|
if (!freshEntry.builders.has(builderId)) {
|
|
960
1108
|
const session = manager.getSession(terminalId);
|
|
961
|
-
if (session) {
|
|
1109
|
+
if (session && session.status === 'running') {
|
|
962
1110
|
freshEntry.builders.set(builderId, terminalId);
|
|
963
1111
|
terminals.push({
|
|
964
1112
|
type: 'builder',
|
|
@@ -973,7 +1121,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
973
1121
|
for (const [shellId, terminalId] of existingEntry.shells) {
|
|
974
1122
|
if (!freshEntry.shells.has(shellId)) {
|
|
975
1123
|
const session = manager.getSession(terminalId);
|
|
976
|
-
if (session) {
|
|
1124
|
+
if (session && session.status === 'running') {
|
|
977
1125
|
freshEntry.shells.set(shellId, terminalId);
|
|
978
1126
|
terminals.push({
|
|
979
1127
|
type: 'shell',
|
|
@@ -1002,9 +1150,14 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
1002
1150
|
(parsed.type === 'shell' && parsed.roleId && freshEntry.shells.has(parsed.roleId));
|
|
1003
1151
|
if (alreadyRegistered)
|
|
1004
1152
|
continue;
|
|
1005
|
-
// Orphaned tmux session — reconnect it
|
|
1153
|
+
// Orphaned tmux session — reconnect it.
|
|
1154
|
+
// Skip architect sessions: launchInstance handles architect creation/reconnection
|
|
1155
|
+
// and has its own exit handler for auto-restart. Reconnecting here races with
|
|
1156
|
+
// the restart logic and can attach to a dead tmux session.
|
|
1157
|
+
if (parsed.type === 'architect')
|
|
1158
|
+
continue;
|
|
1006
1159
|
try {
|
|
1007
|
-
const label =
|
|
1160
|
+
const label = `${parsed.type} ${parsed.roleId || 'unknown'}`;
|
|
1008
1161
|
const newSession = await manager.createSession({
|
|
1009
1162
|
command: 'tmux',
|
|
1010
1163
|
args: ['attach-session', '-t', tmuxName],
|
|
@@ -1012,11 +1165,7 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
1012
1165
|
label,
|
|
1013
1166
|
});
|
|
1014
1167
|
const roleId = parsed.roleId;
|
|
1015
|
-
if (parsed.type === '
|
|
1016
|
-
freshEntry.architect = newSession.id;
|
|
1017
|
-
terminals.push({ type: 'architect', id: 'architect', label: 'Architect', url: `${proxyUrl}?tab=architect`, active: true });
|
|
1018
|
-
}
|
|
1019
|
-
else if (parsed.type === 'builder' && roleId) {
|
|
1168
|
+
if (parsed.type === 'builder' && roleId) {
|
|
1020
1169
|
freshEntry.builders.set(roleId, newSession.id);
|
|
1021
1170
|
terminals.push({ type: 'builder', id: roleId, label: `Builder ${roleId}`, url: `${proxyUrl}?tab=builder-${roleId}`, active: true });
|
|
1022
1171
|
}
|
|
@@ -1034,9 +1183,8 @@ async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
|
1034
1183
|
}
|
|
1035
1184
|
// Atomically replace the cache entry
|
|
1036
1185
|
projectTerminals.set(normalizedPath, freshEntry);
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1039
|
-
const gateStatus = { hasGate: false };
|
|
1186
|
+
// Read gate status from porch YAML files
|
|
1187
|
+
const gateStatus = getGateStatusForProject(projectPath);
|
|
1040
1188
|
return { terminals, gateStatus };
|
|
1041
1189
|
}
|
|
1042
1190
|
// Resolve once at module load: both symlinked and real temp dir paths
|
|
@@ -1059,64 +1207,46 @@ function isTempDirectory(projectPath) {
|
|
|
1059
1207
|
* Get all instances with their status
|
|
1060
1208
|
*/
|
|
1061
1209
|
async function getInstances() {
|
|
1062
|
-
const
|
|
1210
|
+
const knownPaths = getKnownProjectPaths();
|
|
1063
1211
|
const instances = [];
|
|
1064
|
-
for (const
|
|
1212
|
+
for (const projectPath of knownPaths) {
|
|
1065
1213
|
// Skip builder worktrees - they're managed by their parent project
|
|
1066
|
-
if (
|
|
1214
|
+
if (projectPath.includes('/.builders/')) {
|
|
1067
1215
|
continue;
|
|
1068
1216
|
}
|
|
1069
1217
|
// Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
|
|
1070
|
-
if (!
|
|
1071
|
-
if (!fs.existsSync(
|
|
1218
|
+
if (!projectPath.startsWith('remote:')) {
|
|
1219
|
+
if (!fs.existsSync(projectPath)) {
|
|
1072
1220
|
continue;
|
|
1073
1221
|
}
|
|
1074
|
-
if (isTempDirectory(
|
|
1222
|
+
if (isTempDirectory(projectPath)) {
|
|
1075
1223
|
continue;
|
|
1076
1224
|
}
|
|
1077
1225
|
}
|
|
1078
|
-
const basePort = allocation.base_port;
|
|
1079
|
-
const dashboardPort = basePort;
|
|
1080
1226
|
// Encode project path for proxy URL
|
|
1081
|
-
const encodedPath = Buffer.from(
|
|
1227
|
+
const encodedPath = Buffer.from(projectPath).toString('base64url');
|
|
1082
1228
|
const proxyUrl = `/project/${encodedPath}/`;
|
|
1083
1229
|
// Get terminals and gate status from tower's registry
|
|
1084
1230
|
// Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
|
|
1085
|
-
const { terminals, gateStatus } = await getTerminalsForProject(
|
|
1231
|
+
const { terminals, gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
|
|
1086
1232
|
// Project is active if it has any terminals (Phase 4: no port check needed)
|
|
1087
1233
|
const isActive = terminals.length > 0;
|
|
1088
|
-
const ports = [
|
|
1089
|
-
{
|
|
1090
|
-
type: 'Dashboard',
|
|
1091
|
-
port: dashboardPort,
|
|
1092
|
-
url: proxyUrl, // Use tower proxy URL, not raw localhost
|
|
1093
|
-
active: isActive,
|
|
1094
|
-
},
|
|
1095
|
-
];
|
|
1096
1234
|
instances.push({
|
|
1097
|
-
projectPath
|
|
1098
|
-
projectName: getProjectName(
|
|
1099
|
-
basePort,
|
|
1100
|
-
dashboardPort,
|
|
1101
|
-
architectPort: basePort + 1, // Legacy field for backward compat
|
|
1102
|
-
registered: allocation.registered_at,
|
|
1103
|
-
lastUsed: allocation.last_used_at,
|
|
1235
|
+
projectPath,
|
|
1236
|
+
projectName: getProjectName(projectPath),
|
|
1104
1237
|
running: isActive,
|
|
1105
|
-
proxyUrl,
|
|
1106
|
-
architectUrl: `${proxyUrl}?tab=architect`,
|
|
1107
|
-
terminals,
|
|
1108
|
-
ports,
|
|
1238
|
+
proxyUrl,
|
|
1239
|
+
architectUrl: `${proxyUrl}?tab=architect`,
|
|
1240
|
+
terminals,
|
|
1109
1241
|
gateStatus,
|
|
1110
1242
|
});
|
|
1111
1243
|
}
|
|
1112
|
-
// Sort: running first, then by
|
|
1244
|
+
// Sort: running first, then by project name
|
|
1113
1245
|
instances.sort((a, b) => {
|
|
1114
1246
|
if (a.running !== b.running) {
|
|
1115
1247
|
return a.running ? -1 : 1;
|
|
1116
1248
|
}
|
|
1117
|
-
|
|
1118
|
-
const bTime = b.lastUsed ? new Date(b.lastUsed).getTime() : 0;
|
|
1119
|
-
return bTime - aTime;
|
|
1249
|
+
return a.projectName.localeCompare(b.projectName);
|
|
1120
1250
|
});
|
|
1121
1251
|
return instances;
|
|
1122
1252
|
}
|
|
@@ -1189,8 +1319,6 @@ async function getDirectorySuggestions(inputPath) {
|
|
|
1189
1319
|
* Auto-adopts non-codev directories and creates architect terminal
|
|
1190
1320
|
*/
|
|
1191
1321
|
async function launchInstance(projectPath) {
|
|
1192
|
-
// Clean up stale port allocations before launching (handles machine restarts)
|
|
1193
|
-
cleanupStaleEntries();
|
|
1194
1322
|
// Validate path exists
|
|
1195
1323
|
if (!fs.existsSync(projectPath)) {
|
|
1196
1324
|
return { success: false, error: `Path does not exist: ${projectPath}` };
|
|
@@ -1221,38 +1349,8 @@ async function launchInstance(projectPath) {
|
|
|
1221
1349
|
// Phase 4 (Spec 0090): Tower manages terminals directly
|
|
1222
1350
|
// No dashboard-server spawning - tower handles everything
|
|
1223
1351
|
try {
|
|
1224
|
-
// Clear any stale state file
|
|
1225
|
-
const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
|
|
1226
|
-
if (fs.existsSync(stateFile)) {
|
|
1227
|
-
try {
|
|
1228
|
-
fs.unlinkSync(stateFile);
|
|
1229
|
-
}
|
|
1230
|
-
catch {
|
|
1231
|
-
// Ignore - file might not exist or be locked
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
1352
|
// Ensure project has port allocation
|
|
1235
1353
|
const resolvedPath = fs.realpathSync(projectPath);
|
|
1236
|
-
const db = getGlobalDb();
|
|
1237
|
-
let allocation = db
|
|
1238
|
-
.prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
|
|
1239
|
-
.get(projectPath, resolvedPath);
|
|
1240
|
-
if (!allocation) {
|
|
1241
|
-
// Allocate a new port for this project
|
|
1242
|
-
// Find the next available port block (starting at 4200, incrementing by 100)
|
|
1243
|
-
const existingPorts = db
|
|
1244
|
-
.prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
|
|
1245
|
-
.all();
|
|
1246
|
-
let nextPort = 4200;
|
|
1247
|
-
for (const { base_port } of existingPorts) {
|
|
1248
|
-
if (base_port >= nextPort) {
|
|
1249
|
-
nextPort = base_port + 100;
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
|
|
1253
|
-
allocation = { base_port: nextPort };
|
|
1254
|
-
log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
|
|
1255
|
-
}
|
|
1256
1354
|
// Initialize project terminal entry
|
|
1257
1355
|
const entry = getProjectTerminalsEntry(resolvedPath);
|
|
1258
1356
|
// Create architect terminal if not already present
|
|
@@ -1279,14 +1377,26 @@ async function launchInstance(projectPath) {
|
|
|
1279
1377
|
let cmdArgs = cmdParts.slice(1);
|
|
1280
1378
|
// Wrap in tmux for session persistence across Tower restarts
|
|
1281
1379
|
const tmuxName = `architect-${path.basename(projectPath)}`;
|
|
1380
|
+
const sanitizedTmuxName = sanitizeTmuxSessionName(tmuxName);
|
|
1282
1381
|
let activeTmuxSession = null;
|
|
1283
1382
|
if (tmuxAvailable) {
|
|
1284
|
-
|
|
1285
|
-
|
|
1383
|
+
// Reuse existing tmux session if it's still alive (e.g., after
|
|
1384
|
+
// disconnect timeout killed the `tmux attach` process but the
|
|
1385
|
+
// architect process inside tmux kept running).
|
|
1386
|
+
if (tmuxSessionExists(sanitizedTmuxName)) {
|
|
1286
1387
|
cmd = 'tmux';
|
|
1287
|
-
cmdArgs = ['attach-session', '-t',
|
|
1288
|
-
activeTmuxSession =
|
|
1289
|
-
log('INFO', `
|
|
1388
|
+
cmdArgs = ['attach-session', '-t', sanitizedTmuxName];
|
|
1389
|
+
activeTmuxSession = sanitizedTmuxName;
|
|
1390
|
+
log('INFO', `Reconnecting to existing tmux session "${sanitizedTmuxName}" for architect`);
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
const createdName = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
|
|
1394
|
+
if (createdName) {
|
|
1395
|
+
cmd = 'tmux';
|
|
1396
|
+
cmdArgs = ['attach-session', '-t', createdName];
|
|
1397
|
+
activeTmuxSession = createdName;
|
|
1398
|
+
log('INFO', `Created tmux session "${createdName}" for architect`);
|
|
1399
|
+
}
|
|
1290
1400
|
}
|
|
1291
1401
|
}
|
|
1292
1402
|
const session = await manager.createSession({
|
|
@@ -1299,19 +1409,30 @@ async function launchInstance(projectPath) {
|
|
|
1299
1409
|
entry.architect = session.id;
|
|
1300
1410
|
// TICK-001: Save to SQLite for persistence (with tmux session name)
|
|
1301
1411
|
saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
|
|
1302
|
-
// Auto-restart architect on exit
|
|
1412
|
+
// Auto-restart architect on exit
|
|
1303
1413
|
const ptySession = manager.getSession(session.id);
|
|
1304
1414
|
if (ptySession) {
|
|
1305
1415
|
const startedAt = Date.now();
|
|
1306
1416
|
ptySession.on('exit', () => {
|
|
1307
|
-
entry
|
|
1417
|
+
// Re-read entry from the Map — getTerminalsForProject() periodically
|
|
1418
|
+
// replaces the Map entry with a fresh object, so the `entry` captured
|
|
1419
|
+
// in the closure may be stale.
|
|
1420
|
+
const currentEntry = getProjectTerminalsEntry(resolvedPath);
|
|
1421
|
+
if (currentEntry.architect === session.id) {
|
|
1422
|
+
currentEntry.architect = undefined;
|
|
1423
|
+
}
|
|
1308
1424
|
deleteTerminalSession(session.id);
|
|
1309
|
-
//
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1425
|
+
// Check if the tmux session's inner process is still alive.
|
|
1426
|
+
// The node-pty process is `tmux attach` — it exits on disconnect
|
|
1427
|
+
// timeout, but the tmux session (and the architect process inside
|
|
1428
|
+
// it) may still be running. Only kill tmux if the inner process
|
|
1429
|
+
// has also exited (e.g., user typed "exit" or process crashed).
|
|
1430
|
+
const tmuxAlive = activeTmuxSession && tmuxSessionExists(activeTmuxSession);
|
|
1431
|
+
if (activeTmuxSession && !tmuxAlive) {
|
|
1432
|
+
log('INFO', `Tmux session "${activeTmuxSession}" already gone for ${projectPath}`);
|
|
1433
|
+
}
|
|
1434
|
+
else if (tmuxAlive) {
|
|
1435
|
+
log('INFO', `Tmux session "${activeTmuxSession}" still alive for ${projectPath}, preserving for reconnect`);
|
|
1315
1436
|
}
|
|
1316
1437
|
// Only restart if the architect ran for at least 5s (prevents crash loops)
|
|
1317
1438
|
const uptime = Date.now() - startedAt;
|
|
@@ -1319,6 +1440,12 @@ async function launchInstance(projectPath) {
|
|
|
1319
1440
|
log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
|
|
1320
1441
|
return;
|
|
1321
1442
|
}
|
|
1443
|
+
// Kill the stale tmux session so launchInstance creates a fresh one
|
|
1444
|
+
// instead of reconnecting to the dead session.
|
|
1445
|
+
if (activeTmuxSession && tmuxSessionExists(activeTmuxSession)) {
|
|
1446
|
+
killTmuxSession(activeTmuxSession);
|
|
1447
|
+
log('INFO', `Killed stale tmux session "${activeTmuxSession}" before restart`);
|
|
1448
|
+
}
|
|
1322
1449
|
log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
|
|
1323
1450
|
setTimeout(() => {
|
|
1324
1451
|
launchInstance(projectPath).catch((err) => {
|
|
@@ -1427,7 +1554,6 @@ const templatePath = findTemplatePath();
|
|
|
1427
1554
|
// WebSocket server for terminal connections (Phase 2 - Spec 0090)
|
|
1428
1555
|
let terminalWss = null;
|
|
1429
1556
|
// React dashboard dist path (for serving directly from tower)
|
|
1430
|
-
// React dashboard dist path (for serving directly from tower)
|
|
1431
1557
|
// Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
|
|
1432
1558
|
const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
|
|
1433
1559
|
const hasReactDashboard = fs.existsSync(reactDashboardPath);
|
|
@@ -1513,17 +1639,77 @@ const server = http.createServer(async (req, res) => {
|
|
|
1513
1639
|
}));
|
|
1514
1640
|
return;
|
|
1515
1641
|
}
|
|
1642
|
+
// =========================================================================
|
|
1643
|
+
// Tunnel Management Endpoints (Spec 0097 Phase 4)
|
|
1644
|
+
// =========================================================================
|
|
1645
|
+
// POST /api/tunnel/connect — Connect or reconnect tunnel to codevos.ai
|
|
1646
|
+
if (req.method === 'POST' && url.pathname === '/api/tunnel/connect') {
|
|
1647
|
+
try {
|
|
1648
|
+
const config = readCloudConfig();
|
|
1649
|
+
if (!config) {
|
|
1650
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1651
|
+
res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
// Reset circuit breaker if in auth_failed state
|
|
1655
|
+
if (tunnelClient)
|
|
1656
|
+
tunnelClient.resetCircuitBreaker();
|
|
1657
|
+
const client = await connectTunnel(config);
|
|
1658
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1659
|
+
res.end(JSON.stringify({ success: true, state: client.getState() }));
|
|
1660
|
+
}
|
|
1661
|
+
catch (err) {
|
|
1662
|
+
log('ERROR', `Tunnel connect failed: ${err.message}`);
|
|
1663
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1664
|
+
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
1665
|
+
}
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
// POST /api/tunnel/disconnect — Disconnect tunnel from codevos.ai
|
|
1669
|
+
if (req.method === 'POST' && url.pathname === '/api/tunnel/disconnect') {
|
|
1670
|
+
if (tunnelClient) {
|
|
1671
|
+
tunnelClient.disconnect();
|
|
1672
|
+
tunnelClient = null;
|
|
1673
|
+
}
|
|
1674
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1675
|
+
res.end(JSON.stringify({ success: true }));
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
// GET /api/tunnel/status — Return tunnel connection status
|
|
1679
|
+
if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
|
|
1680
|
+
let config = null;
|
|
1681
|
+
try {
|
|
1682
|
+
config = readCloudConfig();
|
|
1683
|
+
}
|
|
1684
|
+
catch {
|
|
1685
|
+
// Config file may be corrupted — treat as unregistered
|
|
1686
|
+
}
|
|
1687
|
+
const state = tunnelClient?.getState() ?? 'disconnected';
|
|
1688
|
+
const uptime = tunnelClient?.getUptime() ?? null;
|
|
1689
|
+
const response = {
|
|
1690
|
+
registered: config !== null,
|
|
1691
|
+
state,
|
|
1692
|
+
uptime,
|
|
1693
|
+
};
|
|
1694
|
+
if (config) {
|
|
1695
|
+
response.towerId = config.tower_id;
|
|
1696
|
+
response.towerName = config.tower_name;
|
|
1697
|
+
response.serverUrl = config.server_url;
|
|
1698
|
+
response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
|
|
1699
|
+
}
|
|
1700
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1701
|
+
res.end(JSON.stringify(response));
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1516
1704
|
// API: List all projects (Spec 0090 Phase 1)
|
|
1517
1705
|
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
1518
1706
|
const instances = await getInstances();
|
|
1519
1707
|
const projects = instances.map((i) => ({
|
|
1520
1708
|
path: i.projectPath,
|
|
1521
1709
|
name: i.projectName,
|
|
1522
|
-
basePort: i.basePort,
|
|
1523
1710
|
active: i.running,
|
|
1524
1711
|
proxyUrl: i.proxyUrl,
|
|
1525
1712
|
terminals: i.terminals.length,
|
|
1526
|
-
lastUsed: i.lastUsed,
|
|
1527
1713
|
}));
|
|
1528
1714
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1529
1715
|
res.end(JSON.stringify({ projects }));
|
|
@@ -1562,7 +1748,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
1562
1748
|
path: instance.projectPath,
|
|
1563
1749
|
name: instance.projectName,
|
|
1564
1750
|
active: instance.running,
|
|
1565
|
-
basePort: instance.basePort,
|
|
1566
1751
|
terminals: instance.terminals,
|
|
1567
1752
|
gateStatus: instance.gateStatus,
|
|
1568
1753
|
}));
|
|
@@ -1590,11 +1775,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1590
1775
|
}
|
|
1591
1776
|
// POST /api/projects/:path/deactivate
|
|
1592
1777
|
if (req.method === 'POST' && action === 'deactivate') {
|
|
1593
|
-
// Check if project
|
|
1594
|
-
const
|
|
1778
|
+
// Check if project is known (has terminals or sessions)
|
|
1779
|
+
const knownPaths = getKnownProjectPaths();
|
|
1595
1780
|
const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
|
|
1596
|
-
const
|
|
1597
|
-
if (!
|
|
1781
|
+
const isKnown = knownPaths.some((p) => p === projectPath || p === resolvedPath);
|
|
1782
|
+
if (!isKnown) {
|
|
1598
1783
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1599
1784
|
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
1600
1785
|
return;
|
|
@@ -1918,41 +2103,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1918
2103
|
res.end(JSON.stringify(result));
|
|
1919
2104
|
return;
|
|
1920
2105
|
}
|
|
1921
|
-
// API: Get tunnel status (cloudflared availability and running tunnel)
|
|
1922
|
-
if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
|
|
1923
|
-
const status = getTunnelStatus();
|
|
1924
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1925
|
-
res.end(JSON.stringify(status));
|
|
1926
|
-
return;
|
|
1927
|
-
}
|
|
1928
|
-
// API: Start cloudflared tunnel
|
|
1929
|
-
if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
|
|
1930
|
-
const result = await startTunnel(port);
|
|
1931
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1932
|
-
res.end(JSON.stringify(result));
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
// API: Stop cloudflared tunnel
|
|
1936
|
-
if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
|
|
1937
|
-
const result = stopTunnel();
|
|
1938
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1939
|
-
res.end(JSON.stringify(result));
|
|
1940
|
-
return;
|
|
1941
|
-
}
|
|
1942
2106
|
// API: Stop an instance
|
|
1943
|
-
// Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
|
|
1944
2107
|
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
|
1945
2108
|
const body = await parseJsonBody(req);
|
|
1946
|
-
|
|
1947
|
-
// Backwards compat: if basePort provided, find the project path
|
|
1948
|
-
if (!targetPath && body.basePort) {
|
|
1949
|
-
const allocations = loadPortAllocations();
|
|
1950
|
-
const allocation = allocations.find((a) => a.base_port === body.basePort);
|
|
1951
|
-
targetPath = allocation?.project_path || '';
|
|
1952
|
-
}
|
|
2109
|
+
const targetPath = body.projectPath;
|
|
1953
2110
|
if (!targetPath) {
|
|
1954
2111
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1955
|
-
res.end(JSON.stringify({ success: false, error: 'Missing projectPath
|
|
2112
|
+
res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
|
|
1956
2113
|
return;
|
|
1957
2114
|
}
|
|
1958
2115
|
const result = await stopInstance(targetPath);
|
|
@@ -1987,8 +2144,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1987
2144
|
const encodedPath = pathParts[2];
|
|
1988
2145
|
const subPath = pathParts.slice(3).join('/');
|
|
1989
2146
|
if (!encodedPath) {
|
|
1990
|
-
res.writeHead(400, { 'Content-Type': '
|
|
1991
|
-
res.end('Missing project path');
|
|
2147
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2148
|
+
res.end(JSON.stringify({ error: 'Missing project path' }));
|
|
1992
2149
|
return;
|
|
1993
2150
|
}
|
|
1994
2151
|
// Decode Base64URL (RFC 4648)
|
|
@@ -2003,11 +2160,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2003
2160
|
projectPath = normalizeProjectPath(projectPath);
|
|
2004
2161
|
}
|
|
2005
2162
|
catch {
|
|
2006
|
-
res.writeHead(400, { 'Content-Type': '
|
|
2007
|
-
res.end('Invalid project path encoding');
|
|
2163
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2164
|
+
res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
|
|
2008
2165
|
return;
|
|
2009
2166
|
}
|
|
2010
|
-
const basePort = await getBasePortForProject(projectPath);
|
|
2011
2167
|
// Phase 4 (Spec 0090): Tower handles everything directly
|
|
2012
2168
|
const isApiCall = subPath.startsWith('api/') || subPath === 'api';
|
|
2013
2169
|
const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
|
|
@@ -2066,24 +2222,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
2066
2222
|
// tmux reconnection, and tmux discovery in one place)
|
|
2067
2223
|
const encodedPath = Buffer.from(projectPath).toString('base64url');
|
|
2068
2224
|
const proxyUrl = `/project/${encodedPath}/`;
|
|
2069
|
-
await getTerminalsForProject(projectPath, proxyUrl);
|
|
2225
|
+
const { gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
|
|
2070
2226
|
// Now read from the refreshed cache
|
|
2071
2227
|
const entry = getProjectTerminalsEntry(projectPath);
|
|
2072
2228
|
const manager = getTerminalManager();
|
|
2073
|
-
// Build state response compatible with React dashboard
|
|
2074
2229
|
const state = {
|
|
2075
2230
|
architect: null,
|
|
2076
2231
|
builders: [],
|
|
2077
2232
|
utils: [],
|
|
2078
2233
|
annotations: [],
|
|
2079
2234
|
projectName: path.basename(projectPath),
|
|
2235
|
+
gateStatus,
|
|
2080
2236
|
};
|
|
2081
2237
|
// Add architect if exists
|
|
2082
2238
|
if (entry.architect) {
|
|
2083
2239
|
const session = manager.getSession(entry.architect);
|
|
2084
2240
|
if (session) {
|
|
2085
2241
|
state.architect = {
|
|
2086
|
-
port:
|
|
2242
|
+
port: 0,
|
|
2087
2243
|
pid: session.pid || 0,
|
|
2088
2244
|
terminalId: entry.architect,
|
|
2089
2245
|
};
|
|
@@ -2096,7 +2252,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2096
2252
|
state.utils.push({
|
|
2097
2253
|
id: shellId,
|
|
2098
2254
|
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2099
|
-
port:
|
|
2255
|
+
port: 0,
|
|
2100
2256
|
pid: session.pid || 0,
|
|
2101
2257
|
terminalId,
|
|
2102
2258
|
});
|
|
@@ -2109,7 +2265,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2109
2265
|
state.builders.push({
|
|
2110
2266
|
id: builderId,
|
|
2111
2267
|
name: `Builder ${builderId}`,
|
|
2112
|
-
port:
|
|
2268
|
+
port: 0,
|
|
2113
2269
|
pid: session.pid || 0,
|
|
2114
2270
|
status: 'running',
|
|
2115
2271
|
phase: '',
|
|
@@ -2167,7 +2323,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2167
2323
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2168
2324
|
res.end(JSON.stringify({
|
|
2169
2325
|
id: shellId,
|
|
2170
|
-
port:
|
|
2326
|
+
port: 0,
|
|
2171
2327
|
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2172
2328
|
terminalId: session.id,
|
|
2173
2329
|
}));
|
|
@@ -2187,45 +2343,79 @@ const server = http.createServer(async (req, res) => {
|
|
|
2187
2343
|
req.on('data', (chunk) => data += chunk.toString());
|
|
2188
2344
|
req.on('end', () => resolve(data));
|
|
2189
2345
|
});
|
|
2190
|
-
const { path: filePath, line } = JSON.parse(body || '{}');
|
|
2346
|
+
const { path: filePath, line, terminalId } = JSON.parse(body || '{}');
|
|
2191
2347
|
if (!filePath || typeof filePath !== 'string') {
|
|
2192
2348
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2193
2349
|
res.end(JSON.stringify({ error: 'Missing path parameter' }));
|
|
2194
2350
|
return;
|
|
2195
2351
|
}
|
|
2196
|
-
// Resolve path relative
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2352
|
+
// Resolve path: use terminal's cwd for relative paths when terminalId is provided
|
|
2353
|
+
let fullPath;
|
|
2354
|
+
if (path.isAbsolute(filePath)) {
|
|
2355
|
+
fullPath = filePath;
|
|
2356
|
+
}
|
|
2357
|
+
else if (terminalId) {
|
|
2358
|
+
const manager = getTerminalManager();
|
|
2359
|
+
const session = manager.getSession(terminalId);
|
|
2360
|
+
if (session) {
|
|
2361
|
+
fullPath = path.join(session.cwd, filePath);
|
|
2362
|
+
}
|
|
2363
|
+
else {
|
|
2364
|
+
log('WARN', `Terminal session ${terminalId} not found, falling back to project root`);
|
|
2365
|
+
fullPath = path.join(projectPath, filePath);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
else {
|
|
2369
|
+
fullPath = path.join(projectPath, filePath);
|
|
2370
|
+
}
|
|
2371
|
+
// Security: symlink-aware containment check
|
|
2372
|
+
// For non-existent files, resolve the parent directory to handle
|
|
2373
|
+
// intermediate symlinks (e.g., /tmp -> /private/tmp on macOS).
|
|
2374
|
+
let resolvedPath;
|
|
2375
|
+
try {
|
|
2376
|
+
resolvedPath = fs.realpathSync(fullPath);
|
|
2377
|
+
}
|
|
2378
|
+
catch {
|
|
2379
|
+
try {
|
|
2380
|
+
resolvedPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
|
|
2381
|
+
}
|
|
2382
|
+
catch {
|
|
2383
|
+
resolvedPath = path.resolve(fullPath);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
let normalizedProject;
|
|
2387
|
+
try {
|
|
2388
|
+
normalizedProject = fs.realpathSync(projectPath);
|
|
2389
|
+
}
|
|
2390
|
+
catch {
|
|
2391
|
+
normalizedProject = path.resolve(projectPath);
|
|
2392
|
+
}
|
|
2393
|
+
const isWithinProject = resolvedPath.startsWith(normalizedProject + path.sep)
|
|
2394
|
+
|| resolvedPath === normalizedProject;
|
|
2395
|
+
if (!isWithinProject) {
|
|
2204
2396
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2205
2397
|
res.end(JSON.stringify({ error: 'Path outside project' }));
|
|
2206
2398
|
return;
|
|
2207
2399
|
}
|
|
2208
|
-
//
|
|
2209
|
-
|
|
2210
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2211
|
-
res.end(JSON.stringify({ error: 'File not found' }));
|
|
2212
|
-
return;
|
|
2213
|
-
}
|
|
2400
|
+
// Non-existent files still create a tab (spec 0101: file viewer shows "File not found")
|
|
2401
|
+
const fileExists = fs.existsSync(fullPath);
|
|
2214
2402
|
const entry = getProjectTerminalsEntry(projectPath);
|
|
2215
2403
|
// Check if already open
|
|
2216
2404
|
for (const [id, tab] of entry.fileTabs) {
|
|
2217
2405
|
if (tab.path === fullPath) {
|
|
2218
2406
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2219
|
-
res.end(JSON.stringify({ id, existing: true, line }));
|
|
2407
|
+
res.end(JSON.stringify({ id, existing: true, line, notFound: !fileExists }));
|
|
2220
2408
|
return;
|
|
2221
2409
|
}
|
|
2222
2410
|
}
|
|
2223
|
-
// Create new file tab
|
|
2224
|
-
const id = `file-${
|
|
2225
|
-
|
|
2411
|
+
// Create new file tab (write-through: in-memory + SQLite)
|
|
2412
|
+
const id = `file-${crypto.randomUUID()}`;
|
|
2413
|
+
const createdAt = Date.now();
|
|
2414
|
+
entry.fileTabs.set(id, { id, path: fullPath, createdAt });
|
|
2415
|
+
saveFileTab(id, projectPath, fullPath, createdAt);
|
|
2226
2416
|
log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
|
|
2227
2417
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2228
|
-
res.end(JSON.stringify({ id, existing: false, line }));
|
|
2418
|
+
res.end(JSON.stringify({ id, existing: false, line, notFound: !fileExists }));
|
|
2229
2419
|
}
|
|
2230
2420
|
catch (err) {
|
|
2231
2421
|
log('ERROR', `Failed to create file tab: ${err.message}`);
|
|
@@ -2280,6 +2470,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2280
2470
|
}
|
|
2281
2471
|
}
|
|
2282
2472
|
catch (err) {
|
|
2473
|
+
log('ERROR', `GET /api/file/:id failed: ${err.message}`);
|
|
2283
2474
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2284
2475
|
res.end(JSON.stringify({ error: err.message }));
|
|
2285
2476
|
}
|
|
@@ -2292,8 +2483,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2292
2483
|
const entry = getProjectTerminalsEntry(projectPath);
|
|
2293
2484
|
const tab = entry.fileTabs.get(tabId);
|
|
2294
2485
|
if (!tab) {
|
|
2295
|
-
res.writeHead(404, { 'Content-Type': '
|
|
2296
|
-
res.end('File tab not found');
|
|
2486
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2487
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2297
2488
|
return;
|
|
2298
2489
|
}
|
|
2299
2490
|
try {
|
|
@@ -2307,8 +2498,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2307
2498
|
res.end(data);
|
|
2308
2499
|
}
|
|
2309
2500
|
catch (err) {
|
|
2310
|
-
|
|
2311
|
-
res.
|
|
2501
|
+
log('ERROR', `GET /api/file/:id/raw failed: ${err.message}`);
|
|
2502
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2503
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2312
2504
|
}
|
|
2313
2505
|
return;
|
|
2314
2506
|
}
|
|
@@ -2341,6 +2533,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2341
2533
|
res.end(JSON.stringify({ success: true }));
|
|
2342
2534
|
}
|
|
2343
2535
|
catch (err) {
|
|
2536
|
+
log('ERROR', `POST /api/file/:id/save failed: ${err.message}`);
|
|
2344
2537
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2345
2538
|
res.end(JSON.stringify({ error: err.message }));
|
|
2346
2539
|
}
|
|
@@ -2352,10 +2545,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2352
2545
|
const tabId = deleteMatch[1];
|
|
2353
2546
|
const entry = getProjectTerminalsEntry(projectPath);
|
|
2354
2547
|
const manager = getTerminalManager();
|
|
2355
|
-
// Check if it's a file tab first (Spec 0092)
|
|
2548
|
+
// Check if it's a file tab first (Spec 0092, write-through: in-memory + SQLite)
|
|
2356
2549
|
if (tabId.startsWith('file-')) {
|
|
2357
2550
|
if (entry.fileTabs.has(tabId)) {
|
|
2358
2551
|
entry.fileTabs.delete(tabId);
|
|
2552
|
+
deleteFileTab(tabId);
|
|
2359
2553
|
log('INFO', `Deleted file tab: ${tabId}`);
|
|
2360
2554
|
res.writeHead(204);
|
|
2361
2555
|
res.end();
|
|
@@ -2495,7 +2689,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2495
2689
|
res.end(JSON.stringify({ modified, staged, untracked }));
|
|
2496
2690
|
}
|
|
2497
2691
|
catch (err) {
|
|
2498
|
-
// Not a git repo or git command failed
|
|
2692
|
+
// Not a git repo or git command failed — return graceful degradation with error field
|
|
2693
|
+
log('WARN', `GET /api/git/status failed: ${err.message}`);
|
|
2499
2694
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2500
2695
|
res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
|
|
2501
2696
|
}
|
|
@@ -2526,8 +2721,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2526
2721
|
const entry = getProjectTerminalsEntry(projectPath);
|
|
2527
2722
|
const tab = entry.fileTabs.get(tabId);
|
|
2528
2723
|
if (!tab) {
|
|
2529
|
-
res.writeHead(404, { 'Content-Type': '
|
|
2530
|
-
res.end('File tab not found');
|
|
2724
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2725
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2531
2726
|
return;
|
|
2532
2727
|
}
|
|
2533
2728
|
const filePath = tab.path;
|
|
@@ -2545,8 +2740,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2545
2740
|
res.end(content);
|
|
2546
2741
|
}
|
|
2547
2742
|
catch (err) {
|
|
2548
|
-
|
|
2549
|
-
res.
|
|
2743
|
+
log('ERROR', `GET /api/annotate/:id/file failed: ${err.message}`);
|
|
2744
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2745
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2550
2746
|
}
|
|
2551
2747
|
return;
|
|
2552
2748
|
}
|
|
@@ -2570,8 +2766,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2570
2766
|
res.end(JSON.stringify({ ok: true }));
|
|
2571
2767
|
}
|
|
2572
2768
|
catch (err) {
|
|
2573
|
-
|
|
2574
|
-
res.
|
|
2769
|
+
log('ERROR', `POST /api/annotate/:id/save failed: ${err.message}`);
|
|
2770
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2771
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2575
2772
|
}
|
|
2576
2773
|
return;
|
|
2577
2774
|
}
|
|
@@ -2583,8 +2780,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2583
2780
|
res.end(JSON.stringify({ mtime: stat.mtimeMs }));
|
|
2584
2781
|
}
|
|
2585
2782
|
catch (err) {
|
|
2586
|
-
|
|
2587
|
-
res.
|
|
2783
|
+
log('ERROR', `GET /api/annotate/:id/api/mtime failed: ${err.message}`);
|
|
2784
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2785
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2588
2786
|
}
|
|
2589
2787
|
return;
|
|
2590
2788
|
}
|
|
@@ -2601,8 +2799,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2601
2799
|
res.end(data);
|
|
2602
2800
|
}
|
|
2603
2801
|
catch (err) {
|
|
2604
|
-
|
|
2605
|
-
res.
|
|
2802
|
+
log('ERROR', `GET /api/annotate/:id/${subRoute} failed: ${err.message}`);
|
|
2803
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2804
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2606
2805
|
}
|
|
2607
2806
|
return;
|
|
2608
2807
|
}
|
|
@@ -2690,8 +2889,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2690
2889
|
}
|
|
2691
2890
|
catch (err) {
|
|
2692
2891
|
log('ERROR', `Request error: ${err.message}`);
|
|
2693
|
-
res.writeHead(500, { 'Content-Type': '
|
|
2694
|
-
res.end(
|
|
2892
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2893
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2695
2894
|
}
|
|
2696
2895
|
});
|
|
2697
2896
|
// SECURITY: Bind to localhost only to prevent network exposure
|
|
@@ -2702,6 +2901,25 @@ server.listen(port, '127.0.0.1', async () => {
|
|
|
2702
2901
|
log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
|
|
2703
2902
|
// TICK-001: Reconcile terminal sessions from previous run
|
|
2704
2903
|
await reconcileTerminalSessions();
|
|
2904
|
+
// Spec 0100: Start background gate watcher for af send notifications
|
|
2905
|
+
startGateWatcher();
|
|
2906
|
+
log('INFO', 'Gate watcher started (10s poll interval)');
|
|
2907
|
+
// Spec 0097 Phase 4: Auto-connect tunnel if registered
|
|
2908
|
+
try {
|
|
2909
|
+
const config = readCloudConfig();
|
|
2910
|
+
if (config) {
|
|
2911
|
+
log('INFO', `Cloud config found, connecting tunnel (tower: ${config.tower_name}, key: ${maskApiKey(config.api_key)})`);
|
|
2912
|
+
await connectTunnel(config);
|
|
2913
|
+
}
|
|
2914
|
+
else {
|
|
2915
|
+
log('INFO', 'No cloud config found, operating in local-only mode');
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
catch (err) {
|
|
2919
|
+
log('WARN', `Failed to read cloud config: ${err.message}. Operating in local-only mode.`);
|
|
2920
|
+
}
|
|
2921
|
+
// Start watching cloud-config.json for changes
|
|
2922
|
+
startConfigWatcher();
|
|
2705
2923
|
});
|
|
2706
2924
|
// Initialize terminal WebSocket server (Phase 2 - Spec 0090)
|
|
2707
2925
|
terminalWss = new WebSocketServer({ noServer: true });
|