episoda 0.2.36 → 0.2.38

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.
@@ -2721,7 +2721,7 @@ var require_package = __commonJS({
2721
2721
  "package.json"(exports2, module2) {
2722
2722
  module2.exports = {
2723
2723
  name: "episoda",
2724
- version: "0.2.35",
2724
+ version: "0.2.38",
2725
2725
  description: "CLI tool for Episoda local development workflow orchestration",
2726
2726
  main: "dist/index.js",
2727
2727
  types: "dist/index.d.ts",
@@ -5452,6 +5452,11 @@ async function startDevServer(projectPath, port = 3e3, moduleUid = "default", op
5452
5452
  });
5453
5453
  injectedEnvVars = result.envVars;
5454
5454
  console.log(`[DevServer] EP998: Loaded ${Object.keys(injectedEnvVars).length} env vars (from ${result.fromCache ? "cache" : "server"})`);
5455
+ const envFilePath = path12.join(projectPath, ".env");
5456
+ if (!fs11.existsSync(envFilePath) && Object.keys(injectedEnvVars).length > 0) {
5457
+ console.log(`[DevServer] EP1004: .env file missing, writing ${Object.keys(injectedEnvVars).length} vars to ${envFilePath}`);
5458
+ writeEnvFile(projectPath, injectedEnvVars);
5459
+ }
5455
5460
  } else {
5456
5461
  console.log(`[DevServer] EP998: No auth config, skipping env var injection`);
5457
5462
  }
@@ -6266,39 +6271,7 @@ async function getWorktreeInfoForModule(moduleUid) {
6266
6271
  }
6267
6272
 
6268
6273
  // src/utils/port-allocator.ts
6269
- var PORT_RANGE_START = 3100;
6270
- var PORT_RANGE_END = 3199;
6271
- var PORT_WARNING_THRESHOLD = 80;
6272
6274
  var portAssignments = /* @__PURE__ */ new Map();
6273
- function allocatePort(moduleUid) {
6274
- const existing = portAssignments.get(moduleUid);
6275
- if (existing) {
6276
- return existing;
6277
- }
6278
- const usedPorts = new Set(portAssignments.values());
6279
- if (usedPorts.size >= PORT_WARNING_THRESHOLD) {
6280
- console.warn(
6281
- `[PortAllocator] Warning: ${usedPorts.size}/${PORT_RANGE_END - PORT_RANGE_START + 1} ports allocated`
6282
- );
6283
- }
6284
- for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
6285
- if (!usedPorts.has(port)) {
6286
- portAssignments.set(moduleUid, port);
6287
- console.log(`[PortAllocator] Assigned port ${port} to ${moduleUid}`);
6288
- return port;
6289
- }
6290
- }
6291
- throw new Error(
6292
- `No available ports in range ${PORT_RANGE_START}-${PORT_RANGE_END}. ${portAssignments.size} modules are using all available ports.`
6293
- );
6294
- }
6295
- function releasePort(moduleUid) {
6296
- const port = portAssignments.get(moduleUid);
6297
- if (port) {
6298
- portAssignments.delete(moduleUid);
6299
- console.log(`[PortAllocator] Released port ${port} from ${moduleUid}`);
6300
- }
6301
- }
6302
6275
  function clearAllPorts() {
6303
6276
  const count = portAssignments.size;
6304
6277
  portAssignments.clear();
@@ -6531,7 +6504,7 @@ var Daemon = class _Daemon {
6531
6504
  // EP837: Prevent concurrent commit syncs (backpressure guard)
6532
6505
  this.commitSyncInProgress = false;
6533
6506
  // EP843: Per-module mutex for tunnel operations
6534
- // Prevents race conditions between autoStartTunnels and module_state_changed handler
6507
+ // EP1003: Prevents race conditions between server-orchestrated tunnel commands
6535
6508
  this.tunnelOperationLocks = /* @__PURE__ */ new Map();
6536
6509
  // moduleUid -> operation promise
6537
6510
  // EP929: Health check polling interval (restored from EP843 removal)
@@ -6816,7 +6789,7 @@ var Daemon = class _Daemon {
6816
6789
  * EP843: Acquire a per-module lock for tunnel operations
6817
6790
  *
6818
6791
  * Prevents race conditions between:
6819
- * - autoStartTunnelsForProject() on auth_success
6792
+ * - Server-orchestrated tunnel_start commands (EP1003)
6820
6793
  * - module_state_changed event handler
6821
6794
  * - Multiple rapid state transitions
6822
6795
  *
@@ -7358,9 +7331,6 @@ var Daemon = class _Daemon {
7358
7331
  this.syncMachineProjectPath(projectId, projectPath).catch((err) => {
7359
7332
  console.warn("[Daemon] EP995: Project path sync failed:", err.message);
7360
7333
  });
7361
- this.autoStartTunnelsForProject(projectPath, projectId).catch((error) => {
7362
- console.error(`[Daemon] EP819: Failed to auto-start tunnels:`, error);
7363
- });
7364
7334
  cleanupStaleCommits(projectPath).then((cleanupResult) => {
7365
7335
  if (cleanupResult.deleted_count > 0) {
7366
7336
  console.log(`[Daemon] EP950: Cleaned up ${cleanupResult.deleted_count} stale commit(s) on connect`);
@@ -7368,8 +7338,8 @@ var Daemon = class _Daemon {
7368
7338
  }).catch((err) => {
7369
7339
  console.warn("[Daemon] EP950: Cleanup on connect failed:", err.message);
7370
7340
  });
7371
- this.reconcileWorktrees(projectId, projectPath).catch((err) => {
7372
- console.warn("[Daemon] EP995: Reconciliation failed:", err.message);
7341
+ this.reconcileWorktrees(projectId, projectPath, client).catch((err) => {
7342
+ console.warn("[Daemon] EP1003: Reconciliation report failed:", err.message);
7373
7343
  });
7374
7344
  });
7375
7345
  client.on("module_state_changed", async (message) => {
@@ -7377,106 +7347,18 @@ var Daemon = class _Daemon {
7377
7347
  const { moduleUid, state, previousState, branchName, devMode, checkoutMachineId } = message;
7378
7348
  console.log(`[Daemon] EP843: Module ${moduleUid} state changed: ${previousState} \u2192 ${state}`);
7379
7349
  if (devMode !== "local") {
7380
- console.log(`[Daemon] EP843: Skipping tunnel action for ${moduleUid} (mode: ${devMode || "unknown"})`);
7350
+ console.log(`[Daemon] EP1003: State change for non-local module ${moduleUid} (mode: ${devMode || "unknown"})`);
7381
7351
  return;
7382
7352
  }
7383
7353
  if (checkoutMachineId && checkoutMachineId !== this.deviceId) {
7384
- console.log(`[Daemon] EP956: Skipping ${moduleUid} (checked out on different machine: ${checkoutMachineId})`);
7354
+ console.log(`[Daemon] EP1003: State change for ${moduleUid} handled by different machine: ${checkoutMachineId}`);
7385
7355
  return;
7386
7356
  }
7387
- const tunnelManager = getTunnelManager();
7388
- await tunnelManager.initialize();
7389
- await this.withTunnelLock(moduleUid, async () => {
7390
- const isInActiveZone = state === "ready" || state === "doing" || state === "review";
7391
- const wasInActiveZone = previousState === "ready" || previousState === "doing" || previousState === "review";
7392
- const startingWork = previousState === "ready" && state === "doing";
7393
- const tunnelNotRunning = !tunnelManager.hasTunnel(moduleUid);
7394
- const needsCrashRecovery = isInActiveZone && tunnelNotRunning;
7395
- if (startingWork || needsCrashRecovery) {
7396
- if (tunnelManager.hasTunnel(moduleUid)) {
7397
- console.log(`[Daemon] EP843: Tunnel already running for ${moduleUid}, skipping start`);
7398
- return;
7399
- }
7400
- console.log(`[Daemon] EP956: Starting tunnel for ${moduleUid} (${previousState} \u2192 ${state})`);
7401
- try {
7402
- let worktree = await getWorktreeInfoForModule(moduleUid);
7403
- if (!worktree) {
7404
- console.error(`[Daemon] EP956: Cannot resolve worktree path for ${moduleUid} (missing config slugs)`);
7405
- return;
7406
- }
7407
- if (!worktree.exists) {
7408
- console.log(`[Daemon] EP956: No worktree for ${moduleUid} at ${worktree.path}, skipping tunnel`);
7409
- return;
7410
- }
7411
- await this.updateModuleWorktreeStatus(moduleUid, "ready", worktree.path);
7412
- const port = allocatePort(moduleUid);
7413
- console.log(`[Daemon] EP956: Using worktree ${worktree.path} on port ${port}`);
7414
- const devConfig = await (0, import_core10.loadConfig)();
7415
- const devServerScript = devConfig?.project_settings?.worktree_dev_server_script;
7416
- const devServerResult = await ensureDevServer(worktree.path, port, moduleUid, devServerScript);
7417
- if (!devServerResult.success) {
7418
- console.error(`[Daemon] EP956: Dev server failed for ${moduleUid}: ${devServerResult.error}`);
7419
- releasePort(moduleUid);
7420
- return;
7421
- }
7422
- const config2 = devConfig;
7423
- const apiUrl = config2?.api_url || "https://episoda.dev";
7424
- const startResult = await tunnelManager.startTunnel({
7425
- moduleUid,
7426
- port,
7427
- onUrl: async (url) => {
7428
- console.log(`[Daemon] EP956: Tunnel URL for ${moduleUid}: ${url}`);
7429
- try {
7430
- await fetchWithAuth(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
7431
- method: "POST",
7432
- body: JSON.stringify({ tunnel_url: url })
7433
- });
7434
- } catch (err) {
7435
- console.warn(`[Daemon] EP956: Failed to report tunnel URL:`, err instanceof Error ? err.message : err);
7436
- }
7437
- },
7438
- onStatusChange: (status, error) => {
7439
- if (status === "error") {
7440
- console.error(`[Daemon] EP956: Tunnel error for ${moduleUid}: ${error}`);
7441
- }
7442
- }
7443
- });
7444
- if (startResult.success) {
7445
- console.log(`[Daemon] EP956: Tunnel started for ${moduleUid}`);
7446
- } else {
7447
- console.error(`[Daemon] EP956: Tunnel failed for ${moduleUid}: ${startResult.error}`);
7448
- releasePort(moduleUid);
7449
- }
7450
- } catch (error) {
7451
- console.error(`[Daemon] EP956: Error starting tunnel for ${moduleUid}:`, error);
7452
- releasePort(moduleUid);
7453
- }
7454
- }
7455
- if (state === "done" && wasInActiveZone) {
7456
- console.log(`[Daemon] EP956: Stopping tunnel for ${moduleUid} (${previousState} \u2192 done)`);
7457
- try {
7458
- await tunnelManager.stopTunnel(moduleUid);
7459
- releasePort(moduleUid);
7460
- console.log(`[Daemon] EP956: Tunnel stopped and port released for ${moduleUid}`);
7461
- const config2 = await (0, import_core10.loadConfig)();
7462
- const apiUrl = config2?.api_url || "https://episoda.dev";
7463
- try {
7464
- await fetchWithAuth(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
7465
- method: "POST",
7466
- body: JSON.stringify({ tunnel_url: null })
7467
- });
7468
- } catch (err) {
7469
- console.warn(`[Daemon] EP956: Failed to clear tunnel URL:`, err instanceof Error ? err.message : err);
7470
- }
7471
- this.cleanupModuleWorktree(moduleUid).catch((err) => {
7472
- console.warn(`[Daemon] EP956: Async cleanup failed for ${moduleUid}:`, err instanceof Error ? err.message : err);
7473
- });
7474
- } catch (error) {
7475
- console.error(`[Daemon] EP956: Error stopping tunnel for ${moduleUid}:`, error);
7476
- releasePort(moduleUid);
7477
- }
7478
- }
7479
- });
7357
+ if (previousState === "ready" && state === "doing") {
7358
+ console.log(`[Daemon] EP1003: Module ${moduleUid} entering doing - server will send tunnel_start`);
7359
+ } else if (state === "done") {
7360
+ console.log(`[Daemon] EP1003: Module ${moduleUid} entering done - server will send tunnel_stop`);
7361
+ }
7480
7362
  }
7481
7363
  });
7482
7364
  client.on("error", (message) => {
@@ -7809,98 +7691,87 @@ var Daemon = class _Daemon {
7809
7691
  * This self-healing mechanism catches modules that transitioned
7810
7692
  * while the daemon was disconnected.
7811
7693
  */
7812
- async reconcileWorktrees(projectId, projectPath) {
7813
- console.log(`[Daemon] EP995: Starting worktree reconciliation for project ${projectId}`);
7694
+ /**
7695
+ * EP1003: Report-only reconciliation
7696
+ * Daemon reports local state to server, server decides what commands to send.
7697
+ * This replaces autonomous worktree creation and tunnel starting.
7698
+ */
7699
+ async reconcileWorktrees(projectId, projectPath, client) {
7700
+ console.log(`[Daemon] EP1003: Starting reconciliation report for project ${projectId}`);
7814
7701
  try {
7815
7702
  if (!this.deviceId) {
7816
- console.log("[Daemon] EP995: Cannot reconcile - deviceId not available yet");
7703
+ console.log("[Daemon] EP1003: Cannot reconcile - deviceId not available yet");
7817
7704
  return;
7818
7705
  }
7819
7706
  const config = await (0, import_core10.loadConfig)();
7820
7707
  if (!config) return;
7821
7708
  const apiUrl = config.api_url || "https://episoda.dev";
7822
- const modulesResponse = await fetchWithAuth(
7823
- `${apiUrl}/api/modules?state=doing,review&dev_mode=local&checkout_machine_id=${this.deviceId}&project_id=${projectId}`
7824
- );
7709
+ const controller = new AbortController();
7710
+ const timeoutId = setTimeout(() => controller.abort(), 1e4);
7711
+ let modulesResponse;
7712
+ try {
7713
+ modulesResponse = await fetchWithAuth(
7714
+ `${apiUrl}/api/modules?state=doing,review&dev_mode=local&checkout_machine_id=${this.deviceId}&project_id=${projectId}`,
7715
+ { signal: controller.signal }
7716
+ );
7717
+ } finally {
7718
+ clearTimeout(timeoutId);
7719
+ }
7825
7720
  if (!modulesResponse.ok) {
7826
- console.warn(`[Daemon] EP995: Failed to fetch modules for reconciliation: ${modulesResponse.status}`);
7721
+ console.warn(`[Daemon] EP1003: Failed to fetch modules for reconciliation: ${modulesResponse.status}`);
7827
7722
  return;
7828
7723
  }
7829
7724
  const modulesData = await modulesResponse.json();
7830
7725
  const modules = modulesData.modules || [];
7831
- if (modules.length === 0) {
7832
- console.log("[Daemon] EP995: No modules need reconciliation");
7833
- return;
7834
- }
7835
- console.log(`[Daemon] EP995: Found ${modules.length} module(s) to check`);
7836
- const worktreeManager = new WorktreeManager(projectPath);
7837
- const initialized = await worktreeManager.initialize();
7838
- if (!initialized) {
7839
- console.error(`[Daemon] EP995: Failed to initialize WorktreeManager`);
7840
- return;
7841
- }
7726
+ console.log(`[Daemon] EP1003: Building reconciliation report for ${modules.length} module(s)`);
7727
+ const tunnelManager = getTunnelManager();
7728
+ await tunnelManager.initialize();
7729
+ const moduleStatuses = [];
7730
+ const expectedModuleUids = new Set(modules.map((m) => m.uid));
7842
7731
  for (const module2 of modules) {
7843
7732
  const moduleUid = module2.uid;
7844
- const branchName = module2.branch_name;
7845
7733
  const worktree = await getWorktreeInfoForModule(moduleUid);
7846
- if (!worktree?.exists) {
7847
- console.log(`[Daemon] EP995: Module ${moduleUid} missing local worktree - creating...`);
7848
- const moduleBranchName = branchName || moduleUid;
7849
- const createResult = await worktreeManager.createWorktree(moduleUid, moduleBranchName, true);
7850
- if (!createResult.success) {
7851
- console.error(`[Daemon] EP995: Failed to create worktree for ${moduleUid}: ${createResult.error}`);
7852
- continue;
7853
- }
7854
- console.log(`[Daemon] EP995: Created worktree for ${moduleUid} at ${createResult.worktreePath}`);
7855
- if (this.deviceId) {
7856
- try {
7857
- await fetchWithAuth(`${apiUrl}/api/modules/${moduleUid}`, {
7858
- method: "PATCH",
7859
- body: JSON.stringify({ checkout_machine_id: this.deviceId })
7860
- });
7861
- console.log(`[Daemon] EP995: Claimed ownership of ${moduleUid}`);
7862
- } catch (ownershipError) {
7863
- console.warn(`[Daemon] EP995: Failed to claim ownership of ${moduleUid}`);
7864
- }
7865
- }
7866
- const newWorktree = await getWorktreeInfoForModule(moduleUid);
7867
- if (!newWorktree?.exists) {
7868
- console.error(`[Daemon] EP995: Worktree still not found after creation`);
7869
- continue;
7870
- }
7871
- const setupConfig = config.project_settings;
7872
- const envVars = await fetchEnvVars2();
7873
- console.log(`[Daemon] EP995: Starting setup for reconciled module ${moduleUid}`);
7874
- await worktreeManager.updateWorktreeStatus(moduleUid, "pending");
7875
- await this.updateModuleWorktreeStatus(moduleUid, "pending", newWorktree.path);
7876
- this.runWorktreeSetupAsync(
7877
- moduleUid,
7878
- worktreeManager,
7879
- setupConfig?.worktree_copy_files || [],
7880
- setupConfig?.worktree_setup_script,
7881
- newWorktree.path,
7882
- envVars
7883
- ).then(() => {
7884
- console.log(`[Daemon] EP995: Setup complete for reconciled ${moduleUid}`);
7885
- this.startTunnelForModule(moduleUid, newWorktree.path);
7886
- }).catch((err) => {
7887
- console.error(`[Daemon] EP995: Setup failed for reconciled ${moduleUid}:`, err);
7734
+ const tunnelRunning = tunnelManager.hasTunnel(moduleUid);
7735
+ const tunnelInfo = tunnelManager.getTunnel(moduleUid);
7736
+ const status = {
7737
+ moduleUid,
7738
+ moduleState: module2.state,
7739
+ worktreeExists: worktree?.exists || false,
7740
+ worktreePath: worktree?.path,
7741
+ tunnelRunning,
7742
+ tunnelPort: tunnelInfo?.port
7743
+ };
7744
+ moduleStatuses.push(status);
7745
+ console.log(`[Daemon] EP1003: Module ${moduleUid}: worktree=${status.worktreeExists}, tunnel=${status.tunnelRunning}`);
7746
+ }
7747
+ const allTunnels = tunnelManager.getAllTunnels();
7748
+ const orphanTunnels = [];
7749
+ for (const tunnel of allTunnels) {
7750
+ if (!expectedModuleUids.has(tunnel.moduleUid)) {
7751
+ console.log(`[Daemon] EP1003: Detected orphan tunnel for ${tunnel.moduleUid} (port ${tunnel.port})`);
7752
+ orphanTunnels.push({
7753
+ moduleUid: tunnel.moduleUid,
7754
+ port: tunnel.port
7888
7755
  });
7889
- } else {
7890
- await this.updateModuleWorktreeStatus(moduleUid, "ready", worktree.path);
7891
- const tunnelManager = getTunnelManager();
7892
- await tunnelManager.initialize();
7893
- if (!tunnelManager.hasTunnel(moduleUid)) {
7894
- console.log(`[Daemon] EP995: Module ${moduleUid} has worktree but no tunnel - starting...`);
7895
- await this.startTunnelForModule(moduleUid, worktree.path);
7896
- } else {
7897
- console.log(`[Daemon] EP995: Module ${moduleUid} OK - worktree and tunnel exist`);
7898
- }
7899
7756
  }
7900
7757
  }
7901
- console.log("[Daemon] EP995: Reconciliation complete");
7758
+ if (orphanTunnels.length > 0) {
7759
+ console.log(`[Daemon] EP1003: Reporting ${orphanTunnels.length} orphan tunnel(s) for server cleanup`);
7760
+ }
7761
+ const report = {
7762
+ projectId,
7763
+ machineId: this.deviceId,
7764
+ modules: moduleStatuses,
7765
+ orphanTunnels: orphanTunnels.length > 0 ? orphanTunnels : void 0
7766
+ };
7767
+ console.log(`[Daemon] EP1003: Sending reconciliation report with ${moduleStatuses.length} module(s)`);
7768
+ await client.send({
7769
+ type: "reconciliation_report",
7770
+ report
7771
+ });
7772
+ console.log("[Daemon] EP1003: Reconciliation report sent - awaiting server commands");
7902
7773
  } catch (error) {
7903
- console.error("[Daemon] EP995: Reconciliation error:", error instanceof Error ? error.message : error);
7774
+ console.error("[Daemon] EP1003: Reconciliation error:", error instanceof Error ? error.message : error);
7904
7775
  throw error;
7905
7776
  }
7906
7777
  }
@@ -8112,222 +7983,10 @@ var Daemon = class _Daemon {
8112
7983
  throw error;
8113
7984
  }
8114
7985
  }
8115
- /**
8116
- * EP959-11: Start tunnel for a module after setup completes
8117
- */
8118
- async startTunnelForModule(moduleUid, worktreePath) {
8119
- const tunnelManager = getTunnelManager();
8120
- await tunnelManager.initialize();
8121
- if (tunnelManager.hasTunnel(moduleUid)) {
8122
- console.log(`[Daemon] EP959: Tunnel already running for ${moduleUid}`);
8123
- return;
8124
- }
8125
- try {
8126
- const config = await (0, import_core10.loadConfig)();
8127
- const apiUrl = config?.api_url || "https://episoda.dev";
8128
- const devServerScript = config?.project_settings?.worktree_dev_server_script;
8129
- const port = allocatePort(moduleUid);
8130
- console.log(`[Daemon] EP959: Post-setup tunnel start for ${moduleUid} on port ${port}`);
8131
- const devServerResult = await ensureDevServer(worktreePath, port, moduleUid, devServerScript);
8132
- if (!devServerResult.success) {
8133
- console.error(`[Daemon] EP959: Dev server failed for ${moduleUid}: ${devServerResult.error}`);
8134
- releasePort(moduleUid);
8135
- return;
8136
- }
8137
- const startResult = await tunnelManager.startTunnel({
8138
- moduleUid,
8139
- port,
8140
- onUrl: async (url) => {
8141
- console.log(`[Daemon] EP959: Tunnel URL for ${moduleUid}: ${url}`);
8142
- try {
8143
- await fetchWithAuth(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
8144
- method: "POST",
8145
- body: JSON.stringify({ tunnel_url: url })
8146
- });
8147
- } catch (err) {
8148
- console.warn(`[Daemon] EP959: Failed to report tunnel URL:`, err instanceof Error ? err.message : err);
8149
- }
8150
- },
8151
- onStatusChange: (status, error) => {
8152
- if (status === "error") {
8153
- console.error(`[Daemon] EP959: Tunnel error for ${moduleUid}: ${error}`);
8154
- }
8155
- }
8156
- });
8157
- if (startResult.success) {
8158
- console.log(`[Daemon] EP959: Tunnel started for ${moduleUid}`);
8159
- } else {
8160
- console.error(`[Daemon] EP959: Tunnel failed for ${moduleUid}: ${startResult.error}`);
8161
- releasePort(moduleUid);
8162
- }
8163
- } catch (error) {
8164
- console.error(`[Daemon] EP959: Error starting tunnel for ${moduleUid}:`, error);
8165
- releasePort(moduleUid);
8166
- }
8167
- }
8168
- /**
8169
- * EP819: Auto-start tunnels for active local modules on daemon connect/reconnect
8170
- *
8171
- * Queries for modules in doing/review state with dev_mode=local that don't have
8172
- * an active tunnel_url, and starts tunnels for each.
8173
- */
8174
- async autoStartTunnelsForProject(projectPath, projectUid) {
8175
- console.log(`[Daemon] EP819: Checking for active local modules to auto-start tunnels...`);
8176
- try {
8177
- const config = await (0, import_core10.loadConfig)();
8178
- if (!config?.access_token) {
8179
- console.warn(`[Daemon] EP819: No access token, skipping tunnel auto-start`);
8180
- return;
8181
- }
8182
- const apiUrl = config.api_url || "https://episoda.dev";
8183
- const response = await fetchWithAuth(
8184
- `${apiUrl}/api/modules?state=doing,review&fields=id,uid,dev_mode,tunnel_url,checkout_machine_id`
8185
- );
8186
- if (!response.ok) {
8187
- console.warn(`[Daemon] EP819: Failed to fetch modules: ${response.status}`);
8188
- return;
8189
- }
8190
- const data = await response.json();
8191
- const modules = data.modules || [];
8192
- const tunnelManager = getTunnelManager();
8193
- await tunnelManager.initialize();
8194
- const activeTunnelUids = tunnelManager.getActiveModuleUids();
8195
- const validModuleUids = new Set(
8196
- modules.filter(
8197
- (m) => m.dev_mode === "local" && (!m.checkout_machine_id || m.checkout_machine_id === this.deviceId)
8198
- ).map((m) => m.uid)
8199
- );
8200
- const orphanedTunnels = activeTunnelUids.filter((uid) => !validModuleUids.has(uid));
8201
- if (orphanedTunnels.length > 0) {
8202
- console.log(`[Daemon] EP956: Found ${orphanedTunnels.length} orphaned tunnels to stop: ${orphanedTunnels.join(", ")}`);
8203
- for (const orphanUid of orphanedTunnels) {
8204
- try {
8205
- await tunnelManager.stopTunnel(orphanUid);
8206
- releasePort(orphanUid);
8207
- console.log(`[Daemon] EP956: Stopped orphaned tunnel and released port for ${orphanUid}`);
8208
- try {
8209
- await fetchWithAuth(`${apiUrl}/api/modules/${orphanUid}/tunnel`, {
8210
- method: "POST",
8211
- body: JSON.stringify({ tunnel_url: null })
8212
- });
8213
- } catch (err) {
8214
- console.warn(`[Daemon] EP956: Failed to clear tunnel URL for ${orphanUid}:`, err instanceof Error ? err.message : err);
8215
- }
8216
- } catch (err) {
8217
- console.error(`[Daemon] EP956: Failed to stop orphaned tunnel ${orphanUid}:`, err instanceof Error ? err.message : err);
8218
- }
8219
- }
8220
- }
8221
- const localModulesNeedingTunnel = modules.filter(
8222
- (m) => m.dev_mode === "local" && (!m.checkout_machine_id || m.checkout_machine_id === this.deviceId) && !tunnelManager.hasTunnel(m.uid)
8223
- );
8224
- if (localModulesNeedingTunnel.length === 0) {
8225
- console.log(`[Daemon] EP819: No local modules need tunnel auto-start`);
8226
- return;
8227
- }
8228
- console.log(`[Daemon] EP956: Found ${localModulesNeedingTunnel.length} local modules needing tunnels`);
8229
- for (const module2 of localModulesNeedingTunnel) {
8230
- const moduleUid = module2.uid;
8231
- const worktree = await getWorktreeInfoForModule(moduleUid);
8232
- if (!worktree) {
8233
- console.warn(`[Daemon] EP956: Cannot resolve worktree for ${moduleUid} (missing config slugs)`);
8234
- continue;
8235
- }
8236
- if (!worktree.exists) {
8237
- console.log(`[Daemon] EP956: No worktree for ${moduleUid} at ${worktree.path}, skipping`);
8238
- continue;
8239
- }
8240
- const port = allocatePort(moduleUid);
8241
- console.log(`[Daemon] EP956: Auto-starting tunnel for ${moduleUid} at ${worktree.path} on port ${port}`);
8242
- const reportTunnelStatus = async (statusData) => {
8243
- try {
8244
- const statusResponse = await fetchWithAuth(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
8245
- method: "POST",
8246
- body: JSON.stringify(statusData)
8247
- });
8248
- if (statusResponse.ok) {
8249
- console.log(`[Daemon] EP819: Tunnel status reported for ${moduleUid}`);
8250
- } else {
8251
- console.warn(`[Daemon] EP819: Failed to report tunnel status: ${statusResponse.statusText}`);
8252
- }
8253
- } catch (reportError) {
8254
- console.warn(`[Daemon] EP819: Error reporting tunnel status:`, reportError);
8255
- }
8256
- };
8257
- (async () => {
8258
- await this.withTunnelLock(moduleUid, async () => {
8259
- if (tunnelManager.hasTunnel(moduleUid)) {
8260
- console.log(`[Daemon] EP956: Tunnel already running for ${moduleUid}, skipping auto-start`);
8261
- return;
8262
- }
8263
- const MAX_RETRIES = 3;
8264
- const RETRY_DELAY_MS = 3e3;
8265
- await reportTunnelStatus({
8266
- tunnel_started_at: (/* @__PURE__ */ new Date()).toISOString(),
8267
- tunnel_error: null
8268
- });
8269
- try {
8270
- const devServerScript = config.project_settings?.worktree_dev_server_script;
8271
- console.log(`[Daemon] EP956: Ensuring dev server is running for ${moduleUid} at ${worktree.path}...`);
8272
- const devServerResult = await ensureDevServer(worktree.path, port, moduleUid, devServerScript);
8273
- if (!devServerResult.success) {
8274
- const errorMsg2 = `Dev server failed to start: ${devServerResult.error}`;
8275
- console.error(`[Daemon] EP956: ${errorMsg2}`);
8276
- await reportTunnelStatus({ tunnel_error: errorMsg2 });
8277
- releasePort(moduleUid);
8278
- return;
8279
- }
8280
- console.log(`[Daemon] EP956: Dev server ready on port ${port}`);
8281
- let lastError;
8282
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
8283
- console.log(`[Daemon] EP819: Starting tunnel for ${moduleUid} (attempt ${attempt}/${MAX_RETRIES})...`);
8284
- const startResult = await tunnelManager.startTunnel({
8285
- moduleUid,
8286
- port,
8287
- onUrl: async (url) => {
8288
- console.log(`[Daemon] EP819: Tunnel URL for ${moduleUid}: ${url}`);
8289
- await reportTunnelStatus({
8290
- tunnel_url: url,
8291
- tunnel_error: null
8292
- });
8293
- },
8294
- onStatusChange: (status, error) => {
8295
- if (status === "error") {
8296
- console.error(`[Daemon] EP819: Tunnel error for ${moduleUid}: ${error}`);
8297
- reportTunnelStatus({ tunnel_error: error || "Tunnel connection error" });
8298
- } else if (status === "reconnecting") {
8299
- console.log(`[Daemon] EP819: Tunnel reconnecting for ${moduleUid}...`);
8300
- }
8301
- }
8302
- });
8303
- if (startResult.success) {
8304
- console.log(`[Daemon] EP819: Tunnel started successfully for ${moduleUid}`);
8305
- return;
8306
- }
8307
- lastError = startResult.error;
8308
- console.warn(`[Daemon] EP819: Tunnel start attempt ${attempt} failed: ${lastError}`);
8309
- if (attempt < MAX_RETRIES) {
8310
- console.log(`[Daemon] EP819: Retrying in ${RETRY_DELAY_MS}ms...`);
8311
- await new Promise((resolve3) => setTimeout(resolve3, RETRY_DELAY_MS));
8312
- }
8313
- }
8314
- const errorMsg = `Tunnel failed after ${MAX_RETRIES} attempts: ${lastError}`;
8315
- console.error(`[Daemon] EP956: ${errorMsg}`);
8316
- await reportTunnelStatus({ tunnel_error: errorMsg });
8317
- releasePort(moduleUid);
8318
- } catch (error) {
8319
- const errorMsg = error instanceof Error ? error.message : String(error);
8320
- console.error(`[Daemon] EP956: Async tunnel startup error:`, error);
8321
- await reportTunnelStatus({ tunnel_error: errorMsg });
8322
- releasePort(moduleUid);
8323
- }
8324
- });
8325
- })();
8326
- }
8327
- } catch (error) {
8328
- console.error(`[Daemon] EP819: Error auto-starting tunnels:`, error);
8329
- }
8330
- }
7986
+ // EP1003: startTunnelForModule removed - server now orchestrates via tunnel_start commands
7987
+ // EP1003: autoStartTunnelsForProject removed - server now orchestrates via reconciliation
7988
+ // Recovery flow: daemon sends reconciliation_report → server processes and sends commands
7989
+ // Orphan tunnel cleanup is now also handled server-side via reconciliation report
8331
7990
  // EP843: startTunnelPolling() removed - replaced by push-based state sync
8332
7991
  // See module_state_changed handler for the new implementation
8333
7992
  /**
@@ -8646,7 +8305,9 @@ var Daemon = class _Daemon {
8646
8305
  method: "POST",
8647
8306
  body: JSON.stringify({
8648
8307
  tunnel_url: url,
8649
- tunnel_error: null
8308
+ tunnel_error: null,
8309
+ restart_reason: "health_check_failure"
8310
+ // EP1003: Server can track restart causes
8650
8311
  })
8651
8312
  });
8652
8313
  } catch (e) {