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.
|
|
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);
|