episoda 0.2.16 → 0.2.17

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.
@@ -2280,7 +2280,7 @@ var require_package = __commonJS({
2280
2280
  "package.json"(exports2, module2) {
2281
2281
  module2.exports = {
2282
2282
  name: "episoda",
2283
- version: "0.2.15",
2283
+ version: "0.2.16",
2284
2284
  description: "CLI tool for Episoda local development workflow orchestration",
2285
2285
  main: "dist/index.js",
2286
2286
  types: "dist/index.d.ts",
@@ -3792,7 +3792,7 @@ var fs7 = __toESM(require("fs"));
3792
3792
  var os2 = __toESM(require("os"));
3793
3793
  var path8 = __toESM(require("path"));
3794
3794
  var packageJson = require_package();
3795
- var Daemon = class {
3795
+ var Daemon = class _Daemon {
3796
3796
  constructor() {
3797
3797
  this.machineId = "";
3798
3798
  this.deviceId = null;
@@ -3812,8 +3812,16 @@ var Daemon = class {
3812
3812
  this.pendingConnections = /* @__PURE__ */ new Set();
3813
3813
  // projectPath
3814
3814
  this.shuttingDown = false;
3815
+ // EP822: Periodic tunnel polling interval
3816
+ this.tunnelPollInterval = null;
3817
+ // 15 seconds
3818
+ // EP822: Prevent concurrent tunnel syncs (backpressure guard)
3819
+ this.tunnelSyncInProgress = false;
3815
3820
  this.ipcServer = new IPCServer();
3816
3821
  }
3822
+ static {
3823
+ this.TUNNEL_POLL_INTERVAL_MS = 15e3;
3824
+ }
3817
3825
  /**
3818
3826
  * Start the daemon
3819
3827
  */
@@ -3830,6 +3838,8 @@ var Daemon = class {
3830
3838
  console.log("[Daemon] IPC server started");
3831
3839
  this.registerIPCHandlers();
3832
3840
  await this.restoreConnections();
3841
+ await this.cleanupOrphanedTunnels();
3842
+ this.startTunnelPolling();
3833
3843
  this.setupShutdownHandlers();
3834
3844
  console.log("[Daemon] Daemon started successfully");
3835
3845
  this.checkAndNotifyUpdates();
@@ -4276,6 +4286,9 @@ var Daemon = class {
4276
4286
  this.flyMachineId = authMessage.flyMachineId;
4277
4287
  console.log(`[Daemon] Fly Machine ID: ${this.flyMachineId}`);
4278
4288
  }
4289
+ this.autoStartTunnelsForProject(projectPath, projectId).catch((error) => {
4290
+ console.error(`[Daemon] EP819: Failed to auto-start tunnels:`, error);
4291
+ });
4279
4292
  });
4280
4293
  client.on("error", (message) => {
4281
4294
  console.error(`[Daemon] Server error for ${projectId}:`, message);
@@ -4469,6 +4482,295 @@ var Daemon = class {
4469
4482
  console.warn("[Daemon] Failed to cache device ID:", error instanceof Error ? error.message : error);
4470
4483
  }
4471
4484
  }
4485
+ /**
4486
+ * EP819: Auto-start tunnels for active local modules on daemon connect/reconnect
4487
+ *
4488
+ * Queries for modules in doing/review state with dev_mode=local that don't have
4489
+ * an active tunnel_url, and starts tunnels for each.
4490
+ */
4491
+ async autoStartTunnelsForProject(projectPath, projectUid) {
4492
+ console.log(`[Daemon] EP819: Checking for active local modules to auto-start tunnels...`);
4493
+ try {
4494
+ const config = await (0, import_core5.loadConfig)();
4495
+ if (!config?.access_token) {
4496
+ console.warn(`[Daemon] EP819: No access token, skipping tunnel auto-start`);
4497
+ return;
4498
+ }
4499
+ const apiUrl = config.api_url || "https://episoda.dev";
4500
+ const response = await fetch(
4501
+ `${apiUrl}/api/modules?state=doing,review&fields=id,uid,dev_mode,tunnel_url,checkout_machine_id`,
4502
+ {
4503
+ headers: {
4504
+ "Authorization": `Bearer ${config.access_token}`,
4505
+ "Content-Type": "application/json"
4506
+ }
4507
+ }
4508
+ );
4509
+ if (!response.ok) {
4510
+ console.warn(`[Daemon] EP819: Failed to fetch modules: ${response.status}`);
4511
+ return;
4512
+ }
4513
+ const data = await response.json();
4514
+ const modules = data.modules || [];
4515
+ const tunnelManager = getTunnelManager();
4516
+ await tunnelManager.initialize();
4517
+ const localModulesNeedingTunnel = modules.filter(
4518
+ (m) => m.dev_mode === "local" && (!m.checkout_machine_id || m.checkout_machine_id === this.deviceId) && !tunnelManager.hasTunnel(m.uid)
4519
+ );
4520
+ if (localModulesNeedingTunnel.length === 0) {
4521
+ console.log(`[Daemon] EP819: No local modules need tunnel auto-start`);
4522
+ return;
4523
+ }
4524
+ console.log(`[Daemon] EP819: Found ${localModulesNeedingTunnel.length} local modules needing tunnels`);
4525
+ for (const module2 of localModulesNeedingTunnel) {
4526
+ const moduleUid = module2.uid;
4527
+ const port = detectDevPort(projectPath);
4528
+ console.log(`[Daemon] EP819: Auto-starting tunnel for ${moduleUid} on port ${port}`);
4529
+ const reportTunnelStatus = async (statusData) => {
4530
+ try {
4531
+ const statusResponse = await fetch(`${apiUrl}/api/modules/${moduleUid}/tunnel`, {
4532
+ method: "POST",
4533
+ headers: {
4534
+ "Authorization": `Bearer ${config.access_token}`,
4535
+ "Content-Type": "application/json"
4536
+ },
4537
+ body: JSON.stringify(statusData)
4538
+ });
4539
+ if (statusResponse.ok) {
4540
+ console.log(`[Daemon] EP819: Tunnel status reported for ${moduleUid}`);
4541
+ } else {
4542
+ console.warn(`[Daemon] EP819: Failed to report tunnel status: ${statusResponse.statusText}`);
4543
+ }
4544
+ } catch (reportError) {
4545
+ console.warn(`[Daemon] EP819: Error reporting tunnel status:`, reportError);
4546
+ }
4547
+ };
4548
+ (async () => {
4549
+ const MAX_RETRIES = 3;
4550
+ const RETRY_DELAY_MS = 2e3;
4551
+ await reportTunnelStatus({
4552
+ tunnel_started_at: (/* @__PURE__ */ new Date()).toISOString(),
4553
+ tunnel_error: null
4554
+ });
4555
+ try {
4556
+ console.log(`[Daemon] EP819: Ensuring dev server is running for ${moduleUid}...`);
4557
+ const devServerResult = await ensureDevServer(projectPath, port, moduleUid);
4558
+ if (!devServerResult.success) {
4559
+ const errorMsg2 = `Dev server failed to start: ${devServerResult.error}`;
4560
+ console.error(`[Daemon] EP819: ${errorMsg2}`);
4561
+ await reportTunnelStatus({ tunnel_error: errorMsg2 });
4562
+ return;
4563
+ }
4564
+ console.log(`[Daemon] EP819: Dev server ready on port ${port}`);
4565
+ let lastError;
4566
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
4567
+ console.log(`[Daemon] EP819: Starting tunnel for ${moduleUid} (attempt ${attempt}/${MAX_RETRIES})...`);
4568
+ const startResult = await tunnelManager.startTunnel({
4569
+ moduleUid,
4570
+ port,
4571
+ onUrl: async (url) => {
4572
+ console.log(`[Daemon] EP819: Tunnel URL for ${moduleUid}: ${url}`);
4573
+ await reportTunnelStatus({
4574
+ tunnel_url: url,
4575
+ tunnel_error: null
4576
+ });
4577
+ },
4578
+ onStatusChange: (status, error) => {
4579
+ if (status === "error") {
4580
+ console.error(`[Daemon] EP819: Tunnel error for ${moduleUid}: ${error}`);
4581
+ reportTunnelStatus({ tunnel_error: error || "Tunnel connection error" });
4582
+ } else if (status === "reconnecting") {
4583
+ console.log(`[Daemon] EP819: Tunnel reconnecting for ${moduleUid}...`);
4584
+ }
4585
+ }
4586
+ });
4587
+ if (startResult.success) {
4588
+ console.log(`[Daemon] EP819: Tunnel started successfully for ${moduleUid}`);
4589
+ return;
4590
+ }
4591
+ lastError = startResult.error;
4592
+ console.warn(`[Daemon] EP819: Tunnel start attempt ${attempt} failed: ${lastError}`);
4593
+ if (attempt < MAX_RETRIES) {
4594
+ console.log(`[Daemon] EP819: Retrying in ${RETRY_DELAY_MS}ms...`);
4595
+ await new Promise((resolve2) => setTimeout(resolve2, RETRY_DELAY_MS));
4596
+ }
4597
+ }
4598
+ const errorMsg = `Tunnel failed after ${MAX_RETRIES} attempts: ${lastError}`;
4599
+ console.error(`[Daemon] EP819: ${errorMsg}`);
4600
+ await reportTunnelStatus({ tunnel_error: errorMsg });
4601
+ } catch (error) {
4602
+ const errorMsg = error instanceof Error ? error.message : String(error);
4603
+ console.error(`[Daemon] EP819: Async tunnel startup error:`, error);
4604
+ await reportTunnelStatus({ tunnel_error: `Unexpected error: ${errorMsg}` });
4605
+ }
4606
+ })();
4607
+ }
4608
+ } catch (error) {
4609
+ console.error(`[Daemon] EP819: Error auto-starting tunnels:`, error);
4610
+ }
4611
+ }
4612
+ /**
4613
+ * EP822: Start periodic tunnel polling
4614
+ *
4615
+ * Polls every 30 seconds to detect module state changes and manage tunnels:
4616
+ * - Start tunnels for modules entering doing/review state
4617
+ * - Stop tunnels for modules leaving doing/review state
4618
+ */
4619
+ startTunnelPolling() {
4620
+ if (this.tunnelPollInterval) {
4621
+ console.log("[Daemon] EP822: Tunnel polling already running");
4622
+ return;
4623
+ }
4624
+ console.log(`[Daemon] EP822: Starting tunnel polling (every ${_Daemon.TUNNEL_POLL_INTERVAL_MS / 1e3}s)`);
4625
+ this.tunnelPollInterval = setInterval(() => {
4626
+ this.syncTunnelsWithActiveModules().catch((error) => {
4627
+ console.error("[Daemon] EP822: Tunnel sync error:", error);
4628
+ });
4629
+ }, _Daemon.TUNNEL_POLL_INTERVAL_MS);
4630
+ }
4631
+ /**
4632
+ * EP822: Stop periodic tunnel polling
4633
+ */
4634
+ stopTunnelPolling() {
4635
+ if (this.tunnelPollInterval) {
4636
+ clearInterval(this.tunnelPollInterval);
4637
+ this.tunnelPollInterval = null;
4638
+ console.log("[Daemon] EP822: Tunnel polling stopped");
4639
+ }
4640
+ }
4641
+ /**
4642
+ * EP822: Clean up orphaned tunnels from previous daemon runs
4643
+ *
4644
+ * When the daemon crashes or is killed, tunnels may continue running.
4645
+ * This method stops any tunnels that are running but shouldn't be,
4646
+ * ensuring a clean slate on startup.
4647
+ */
4648
+ async cleanupOrphanedTunnels() {
4649
+ try {
4650
+ const tunnelManager = getTunnelManager();
4651
+ const runningTunnels = tunnelManager.getAllTunnels();
4652
+ if (runningTunnels.length === 0) {
4653
+ return;
4654
+ }
4655
+ console.log(`[Daemon] EP822: Found ${runningTunnels.length} orphaned tunnel(s) from previous run, cleaning up...`);
4656
+ for (const tunnel of runningTunnels) {
4657
+ try {
4658
+ await tunnelManager.stopTunnel(tunnel.moduleUid);
4659
+ await stopDevServer(tunnel.moduleUid);
4660
+ console.log(`[Daemon] EP822: Cleaned up orphaned tunnel for ${tunnel.moduleUid}`);
4661
+ } catch (error) {
4662
+ console.error(`[Daemon] EP822: Failed to clean up tunnel for ${tunnel.moduleUid}:`, error);
4663
+ }
4664
+ }
4665
+ console.log("[Daemon] EP822: Orphaned tunnel cleanup complete");
4666
+ } catch (error) {
4667
+ console.error("[Daemon] EP822: Failed to clean up orphaned tunnels:", error);
4668
+ }
4669
+ }
4670
+ /**
4671
+ * EP822: Sync tunnels with active modules
4672
+ *
4673
+ * Compares running tunnels against modules in doing/review state.
4674
+ * - Starts tunnels for modules that need them
4675
+ * - Stops tunnels for modules that left active zone
4676
+ *
4677
+ * Fixes from peer review:
4678
+ * - Backpressure guard prevents concurrent syncs
4679
+ * - Uses deviceId (UUID) instead of machineId (string) for machine comparison
4680
+ * - Groups modules by project_id for correct multi-project routing
4681
+ */
4682
+ async syncTunnelsWithActiveModules() {
4683
+ if (this.tunnelSyncInProgress) {
4684
+ console.log("[Daemon] EP822: Sync already in progress, skipping");
4685
+ return;
4686
+ }
4687
+ if (this.liveConnections.size === 0) {
4688
+ return;
4689
+ }
4690
+ this.tunnelSyncInProgress = true;
4691
+ try {
4692
+ const config = await (0, import_core5.loadConfig)();
4693
+ if (!config?.access_token) {
4694
+ return;
4695
+ }
4696
+ const apiUrl = config.api_url || "https://episoda.dev";
4697
+ const tunnelManager = getTunnelManager();
4698
+ const runningTunnels = tunnelManager.getAllTunnels();
4699
+ const runningModuleUids = new Set(runningTunnels.map((t) => t.moduleUid));
4700
+ const response = await fetch(
4701
+ `${apiUrl}/api/modules?state=doing,review&fields=id,uid,dev_mode,tunnel_url,checkout_machine_id,project_id`,
4702
+ {
4703
+ headers: {
4704
+ "Authorization": `Bearer ${config.access_token}`,
4705
+ "Content-Type": "application/json"
4706
+ }
4707
+ }
4708
+ );
4709
+ if (!response.ok) {
4710
+ console.warn(`[Daemon] EP822: Failed to fetch modules: ${response.status}`);
4711
+ return;
4712
+ }
4713
+ const data = await response.json();
4714
+ const modules = data.modules || [];
4715
+ const activeLocalModules = modules.filter(
4716
+ (m) => m.dev_mode === "local" && (!m.checkout_machine_id || m.checkout_machine_id === this.deviceId)
4717
+ );
4718
+ const activeModuleUids = new Set(activeLocalModules.map((m) => m.uid));
4719
+ const modulesNeedingTunnel = activeLocalModules.filter(
4720
+ (m) => !runningModuleUids.has(m.uid)
4721
+ );
4722
+ const tunnelsToStop = runningTunnels.filter(
4723
+ (t) => !activeModuleUids.has(t.moduleUid)
4724
+ );
4725
+ if (modulesNeedingTunnel.length > 0) {
4726
+ console.log(`[Daemon] EP822: Starting tunnels for ${modulesNeedingTunnel.length} module(s)`);
4727
+ const modulesByProject = /* @__PURE__ */ new Map();
4728
+ for (const module2 of modulesNeedingTunnel) {
4729
+ const projectId = module2.project_id;
4730
+ if (!projectId) continue;
4731
+ if (!modulesByProject.has(projectId)) {
4732
+ modulesByProject.set(projectId, []);
4733
+ }
4734
+ modulesByProject.get(projectId).push(module2);
4735
+ }
4736
+ const trackedProjects = getAllProjects();
4737
+ for (const [projectId, projectModules] of modulesByProject) {
4738
+ const project = trackedProjects.find((p) => p.id === projectId);
4739
+ if (project) {
4740
+ console.log(`[Daemon] EP822: Starting ${projectModules.length} tunnel(s) for project ${projectId}`);
4741
+ await this.autoStartTunnelsForProject(project.path, project.id);
4742
+ } else {
4743
+ console.warn(`[Daemon] EP822: Project ${projectId} not tracked locally, skipping ${projectModules.length} module(s)`);
4744
+ }
4745
+ }
4746
+ }
4747
+ if (tunnelsToStop.length > 0) {
4748
+ console.log(`[Daemon] EP822: Stopping ${tunnelsToStop.length} orphaned tunnel(s)`);
4749
+ for (const tunnel of tunnelsToStop) {
4750
+ try {
4751
+ await tunnelManager.stopTunnel(tunnel.moduleUid);
4752
+ await stopDevServer(tunnel.moduleUid);
4753
+ try {
4754
+ await fetch(`${apiUrl}/api/modules/${tunnel.moduleUid}/tunnel`, {
4755
+ method: "DELETE",
4756
+ headers: {
4757
+ "Authorization": `Bearer ${config.access_token}`
4758
+ }
4759
+ });
4760
+ console.log(`[Daemon] EP822: Tunnel stopped and cleared for ${tunnel.moduleUid}`);
4761
+ } catch {
4762
+ }
4763
+ } catch (error) {
4764
+ console.error(`[Daemon] EP822: Failed to stop tunnel for ${tunnel.moduleUid}:`, error);
4765
+ }
4766
+ }
4767
+ }
4768
+ } catch (error) {
4769
+ console.error("[Daemon] EP822: Error syncing tunnels:", error);
4770
+ } finally {
4771
+ this.tunnelSyncInProgress = false;
4772
+ }
4773
+ }
4472
4774
  /**
4473
4775
  * Gracefully shutdown daemon
4474
4776
  */
@@ -4476,6 +4778,7 @@ var Daemon = class {
4476
4778
  if (this.shuttingDown) return;
4477
4779
  this.shuttingDown = true;
4478
4780
  console.log("[Daemon] Shutting down...");
4781
+ this.stopTunnelPolling();
4479
4782
  for (const [projectPath, connection] of this.connections) {
4480
4783
  if (connection.reconnectTimer) {
4481
4784
  clearTimeout(connection.reconnectTimer);